Skip to content

inspecting.kt

src/jvmMain/kotlin/afield/app/server/inspecting.kt · 511 lines

package and imports — 47 lines, from line 1
package afield.app.server

import afield.ai.agent.*
import afield.ai.conversation.*
import afield.ai.tools.*
import afield.animation.gesture.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.app.diagnostics.*
import afield.app.notifications.*
import afield.app.protocol.*
import afield.app.server.*
import afield.app.settings.*
import afield.audio.synth.*
import afield.data.table.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.history.*
import afield.document.persistence.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.graphics.color.*
import afield.graphics.paint.*
import afield.graphics.vulkan.*
import afield.math.functions.*
import afield.media.capture.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.completion.*
import afield.runtime.execution.*
import afield.runtime.kotlin.*
import afield.runtime.lifecycle.*
import afield.tools.automation.*
import afield.tools.docs.*
import afield.ui.controls.*
import afield.ui.layout.*
import afield.ui.notation.*
import afield.ui.output.*
import afield.ui.output.templates.*
import afield.ui.slides.*
import afield.ui.visualization.*
import io.ktor.websocket.*
import kotlinx.serialization.json.*
import org.json.JSONObject
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible

A two-way debug inspector for a box: Box.inspect shows a set of objects in an iframe at /inspect and lets the page edit their fields.

The shape of what is shown is described by a list of TypeDefs. extractMappedData walks the roots against those definitions and records a setter for each editable path, and DebugSocketBridge sends the extract to the page and applies the edits it sends back. initInspecting installs the raw socket handler that routes inspector messages to the right bridge.

data class TypeDef

Type definition model mirroring the frontend expectations

data class TypeDef(
    val path: String,
    val outputPath: String? = null,
    val type: String? = null,               // "string" | "number" | "boolean" | "choice"
    val options: List<String>? = null,
    val min: Number? = null,
    val max: Number? = null,
    val step: Number? = null,
    val readOnly: Boolean? = null,
    val priority: Int? = null,
    val units: String? = null,
    val unitsHtml: String? = null,
    val getter: ((Any?, String) -> Any?)? = null,
    val setter: ((Any?, String, Any?) -> Unit)? = null,
    val action: ((Any?, String) -> Unit)? = null,
    val actionTitle: String? = null
)

Utility to split paths ("a/b/*/c")

private fun splitPath(path: String) = path.split("/").filter { it.isNotBlank() }

Matches a concrete path against a pattern (with '*' wildcards). Returns captured wildcards or null.

private fun matchPath(concrete: List<String>, pattern: List<String>): List<String>? {
    if (concrete.size != pattern.size) return null
    val caps = mutableListOf<String>()
    for (i in concrete.indices) {
        val p = pattern[i]
        if (p == "*") caps += concrete[i] else if (p != concrete[i]) return null
    }
    return caps
}

private fun buildOutputPathSegments(outputPath: String?, actualSegs: List<String>, wildcards: List<String>): List<String> {
    val capsIter = wildcards.iterator()
    val segs = if (outputPath != null) splitPath(outputPath) else actualSegs
    val out = mutableListOf<String>()
    segs.forEach { seg ->
        if (seg == "*") out += if (capsIter.hasNext()) capsIter.next() else "*"
        else out += seg
    }
    return out
}

Reflection helpers ----------------------------------------------------------

private fun getProp(obj: Any?, key: String): Any? {
    if (obj == null) return null
    return when (obj) {
        is Map<*, *> -> obj[key]
        is List<*> -> key.toIntOrNull()?.let { idx -> obj.getOrNull(idx) }
        else -> {
            val prop = obj::class.memberProperties.firstOrNull { it.name == key } ?: return null
            prop.isAccessible = true
            prop.getter.call(obj)
        }
    }
}

private fun setProp(obj: Any?, key: String, value: Any?) {
    if (obj == null) return
    when (obj) {
        is MutableMap<*, *> -> {
            @Suppress("UNCHECKED_CAST")
            val map = obj as MutableMap<String, Any?>
            map[key] = value
        }
        is MutableList<*> -> key.toIntOrNull()?.let { idx ->
            @Suppress("UNCHECKED_CAST")
            val list = obj as MutableList<Any?>
            while (list.size <= idx) list.add(null)
            list[idx] = value
        }

        else -> {
            val prop = obj::class.memberProperties.firstOrNull { it.name == key } ?: return
            if (prop is KMutableProperty1<*, *>) {
                prop.isAccessible = true
                @Suppress("UNCHECKED_CAST") (prop as KMutableProperty1<Any?, Any?>).set(obj, value)
            }
        }
    }
}

data class ExtractResult

Extractor -------------------------------------------------------------------

data class ExtractResult(
    val data: Any,                // JSON-serializable (Map/List/scalars)
    val typeDefs: List<TypeDef>, val setters: MutableMap<String, MutableList<(Any?) -> Unit>>
)

fun extractMappedData(roots: List<Any?>, specs: List<TypeDef>): ExtractResult

body — 107 lines, from line 149
fun extractMappedData(roots: List<Any?>, specs: List<TypeDef>): ExtractResult {
    val dataList = mutableListOf<Any>()
    val setters = mutableMapOf<String, MutableList<(Any?) -> Unit>>()

    fun recordSetter(path: String, fn: (Any?) -> Unit) {
        setters.getOrPut(path) { mutableListOf() }.add(fn)
    }

    fun buildForRoot(root: Any?): Any {
        fun ensureContainer(target: MutableMap<String, Any?>, segs: List<String>, idx: Int): Any? {
            val key = segs[idx]
            if (idx == segs.lastIndex) return target
            val nextIsNum = segs.getOrNull(idx + 1)?.toIntOrNull() != null
            val next = target.getOrPut(key) { if (nextIsNum) mutableListOf<Any?>() else mutableMapOf<String, Any?>() }
            return next
        }

        fun setIntermediate(target: MutableMap<String, Any?>, segs: List<String>, value: Any?) {
            var cur: Any? = target
            for (i in segs.indices) {
                val seg = segs[i]
                val isLast = i == segs.lastIndex
                if (cur is MutableMap<*, *>) {
                    @Suppress("UNCHECKED_CAST")
                    val map = cur as MutableMap<String, Any?>
                    if (isLast) map[seg] = value
                    else cur = map.getOrPut(seg) {
                        val nextIsNum = segs.getOrNull(i + 1)?.toIntOrNull() != null
                        if (nextIsNum) mutableListOf<Any?>() else mutableMapOf<String, Any?>()
                    }
                } else if (cur is MutableList<*>) {
                    @Suppress("UNCHECKED_CAST")
                    val list = cur as MutableList<Any?>
                    val idx = seg.toIntOrNull() ?: return
                    while (list.size <= idx) list.add(null)
                    if (isLast) list[idx] = value
                    else {
                        val nextIsNum = segs.getOrNull(i + 1)?.toIntOrNull() != null
                        var next = list[idx]
                        if (next == null) {
                            next = if (nextIsNum) mutableListOf<Any?>() else mutableMapOf<String, Any?>()
                            list[idx] = next
                        }
                        cur = next
                    }
                } else return
            }
        }

        val out = mutableMapOf<String, Any?>()

        fun walk(node: Any?, path: List<String>) {
            // normal matches
            specs.forEach { spec ->
                val pattern = splitPath(spec.path)
                val caps = matchPath(path, pattern) ?: return@forEach
                val outSegs = buildOutputPathSegments(spec.outputPath, path, caps)
                setIntermediate(out, outSegs, node)
                recordSetter(outSegs.joinToString("/")) { newVal ->
                    // naive setter: follow path on original root
                    var cur: Any? = root
                    for (i in path.indices) {
                        if (i == path.lastIndex) {
                            val parent = cur
                            val key = path[i]
                            if (spec.setter != null) spec.setter.invoke(parent, key, newVal)
                            else setProp(parent, key, newVal)
                        } else cur = getProp(cur, path[i])
                    }
                }
            }

            // Materialize action entries even if property missing
            specs.forEach { spec ->
                if (spec.type == "action") {
                    val pattern = splitPath(spec.path)
                    if (pattern.size == path.size + 1) {
                        val fullPath = path + pattern.last()
                        val caps = matchPath(fullPath, pattern) ?: return@forEach
                        if (pattern.last() != "*") {
                            val outSegs = buildOutputPathSegments(spec.outputPath, fullPath, caps)
                            setIntermediate(out, outSegs, spec.actionTitle ?: pattern.last())
                        }
                    }
                }
            }

            when (node) {
                is Map<*, *> -> node.forEach { (k, v) ->
                    if (k != null) walk(v, path + k.toString())
                }

                is List<*> -> node.forEachIndexed { idx, v -> walk(v, path + idx.toString()) }
                else -> {}
            }
        }

        walk(root, emptyList())
        return out
    }

    roots.forEach { dataList += buildForRoot(it) }

    return ExtractResult(
        data = if (dataList.size == 1) dataList.first() else dataList, typeDefs = specs, setters = setters
    )
}

fun applyPathUpdate(setters: Map<String, List<(Any?) -> Unit>>, path: String, newValue: Any?) {

Apply an incoming path update back to all matched setters

fun applyPathUpdate(setters: Map<String, List<(Any?) -> Unit>>, path: String, newValue: Any?) {
    setters[path]?.forEach { it(newValue) }
}

fun ExtractResult.toMessage(debugUid: String): String

Serialize helpers

fun ExtractResult.toMessage(debugUid: String): String = Json.encodeToString(
    JsonObject(
        mapOf(
            "action" to JsonPrimitive("setState"),
            "state" to toJsonElement(data),
            "typeDefs" to JsonArray(typeDefs.map { typeDefToJson(it) }),
            "debug_uid" to JsonPrimitive(debugUid)
        )
    )
)

Flatten a JSON-friendly structure into path -> value map

private fun flattenState(node: Any?, path: List<String>, out: MutableMap<String, Any?>) {
    when (node) {
        is Map<*, *> -> node.forEach { (k, v) ->
            if (k != null) flattenState(v, path + k.toString(), out)
        }
        is List<*> -> node.forEachIndexed { idx, v ->
            flattenState(v, path + idx.toString(), out)
        }
        else -> out[path.joinToString("/")] = node
    }
}

private fun normalizeStateNode(node: Any?, expectedRoots: Int): Any? {
    // If inspector sent a single-element list but we have a single root map, unwrap it.
    if (expectedRoots == 1 && node is List<*> && node.size == 1) {
        return node[0]
    }
    return node
}

class DebugSocketBridge

WebSocket handling skeleton (assumes send(text: String) exists)

class DebugSocketBridge(
    private val roots: List<Any?>, private val specs: List<TypeDef>, var sendFn: (String) -> Unit
) {
    private var extract: ExtractResult = extractMappedData(roots, specs)

fun sendInitial(debugUid: String)

    fun sendInitial(debugUid: String) {
        sendFn(extract.toMessage(debugUid))
    }

fun handleMessage(text: String)

body — 64 lines, from line 306
    fun handleMessage(text: String) {
        val msgElement = Json.parseToJsonElement(text)
        val msg = jsonElementToMap(msgElement)
        when (msg["action"]) {
            "hello" -> {
                val uid = msg["debug_uid"]?.toString() ?: "server"
                sendInitial(uid)
            }
            "pathUpdate" -> {
                val path = msg["path"] as? String ?: return
                val newValue = msg["newValue"]
                applyPathUpdate(extract.setters, path, newValue)
                sendFn(extract.toMessage(msg["debug_uid"]?.toString() ?: "server"))
            }
            "pathAction" -> {
                val path = msg["path"] as? String ?: return
                val segs = splitPath(path)
                specs.forEach { spec ->
                    val pattern = splitPath(spec.path)
                    val caps = matchPath(segs, pattern)
                    if (caps != null && spec.type == "action" && spec.action != null) {
                        roots.forEach { r ->
                            var parent: Any? = r
                            for (i in 0 until segs.size - 1) {
                                parent = getProp(parent, segs[i])
                            }
                            val key = segs.last()
                            spec.action.invoke(parent, key)
                        }
                    }
                }
            }

            "stateUpdate" -> {
                val incomingRaw = msg["state"] ?: return
                val expectedRoots = roots.count { it != null }.coerceAtLeast(1)
                val incomingState = normalizeStateNode(incomingRaw, expectedRoots)
                val currentFlat = mutableMapOf<String, Any?>()
                flattenState(extract.data, emptyList(), currentFlat)
                val incomingFlat = mutableMapOf<String, Any?>()
                flattenState(incomingState, emptyList(), incomingFlat)
                incomingFlat.forEach { (path, value) ->
                    val cur = currentFlat[path]
                    if (cur != value) {
                        applyPathUpdate(extract.setters, path, value)
                    }
                }
                extract = extractMappedData(roots.filterNotNull(), specs)
                sendFn(extract.toMessage(msg["debug_uid"]?.toString() ?: "server"))
            }

            "requestTypeDefs" -> {
                val payload = JsonObject(
                    mapOf(
                        "action" to JsonPrimitive("typeDefs"),
                        "typeDefs" to JsonArray(specs.map { typeDefToJson(it) }),
                        "debug_uid" to JsonPrimitive(msg["debug_uid"]?.toString() ?: "server")
                    )
                )
                sendFn(Json.encodeToString(payload))
            }
        }
    }
}

Example usage:

val typeDefs = listOf( TypeDef(path = "user/name", type = "string", priority = 10), TypeDef(path = "user/age", type = "number", min = 0, max = 120, units = "yr", priority = 9), TypeDef(path = "settings/theme", type = "choice", options = listOf("dark", "light", "system")) )

val roots = listOf(appState) // or any list of roots

val bridge = DebugSocketBridge(roots, typeDefs) { txt -> send(txt) } // send is your existing function bridge.sendInitial("server") // On incoming WS text: // bridge.handleMessage(incomingText)

val knownDebugInspectors = mutableMapOf<String, DebugSocketBridge>()

fun initInspecting()

@RunsAt(installHandlers)
fun initInspecting() {
    rawHandlers.getOrPut("__inspecting__") { mutableSetOf() }.add { text ->
        val s = thisSession.get()
        try {
            val t = JSONObject(text)
            if (t.has("debug_uid") && t.has("type") && t.getString("type") == "inspector") {
                knownDebugInspectors[t.getString("debug_uid")]?.sendFn = {
                    inMain {
                        s.send(it)
                    }
                }
                knownDebugInspectors[t.getString("debug_uid")]?.handleMessage(text)
            }
            Handled.yes
        } catch (e: Exception) {
            Handled.no
        }
    }
}

fun Box.inspect

fun Box.inspect(
    channel: String = "debug", name: String = "inspect", a: Any?, typeDefs: List<TypeDef>
): DebugSocketBridge {
    val aa = a ?: "null"
    val ln = Inspector.linkFor(aa, "/inspect")
    val uid = this.id + "_" + name
    display(
        channel, """
<iframe src='../${ln}&uid=${uid}' style='border:none;padding:0;margin:0'>
"""
    )
    val roots = if (a is Collection<*>) a.toList() else mutableListOf(a)
    val s = thisSession.get()
    val eval = knownDebugInspectors.computeIfAbsent(uid) {
        DebugSocketBridge(roots, typeDefs) {
            inMain {
                s.send(it)
            }
        }
    }
    return eval
}

fun initDemo(): Pair<MutableMap<String, Any>, List<TypeDef>>

Simple demo initializer for quick testing (prints outbound JSON to stdout)

fun initDemo(): Pair<MutableMap<String, Any>, List<TypeDef>> {
    val demoState = mutableMapOf(
        "user" to mutableMapOf("name" to "Demo User", "age" to 42),
        "settings" to mutableMapOf("theme" to "dark", "debug" to true),
        "items" to mutableListOf(
            mutableMapOf("id" to 1, "label" to "One", "enabled" to true),
            mutableMapOf("id" to 2, "label" to "Two", "enabled" to false)
        )
    )

    val demoTypeDefs = listOf(
        TypeDef(path = "user/name", type = "string", priority = 10),
        TypeDef(path = "user/age", type = "number", min = 0, max = 120, priority = 9, units = "yr"),
        TypeDef(path = "settings/theme", type = "choice", options = listOf("dark", "light", "system"), priority = 8),
        TypeDef(path = "settings/debug", type = "boolean", priority = 7),
        TypeDef(path = "items/*/label", type = "string", priority = 5),
        TypeDef(path = "items/*/enabled", type = "boolean", priority = 4)
    )

    return demoState to demoTypeDefs
}

Helpers to avoid external JSON deps ----------------------------------------

source — 54 lines, from line 457
private fun toJsonElement(value: Any?): JsonElement = when (value) {
    null -> JsonNull
    is Boolean -> JsonPrimitive(value)
    is Number -> JsonPrimitive(value)
    is String -> JsonPrimitive(value)
    is Map<*, *> -> {
        val mapped = value.entries.associate { (k, v) -> k.toString() to toJsonElement(v) }
        JsonObject(mapped)
    }

    is List<*> -> JsonArray(value.map { toJsonElement(it) })
    else -> JsonPrimitive(value.toString())
}

private fun jsonElementToValue(el: JsonElement): Any? = when (el) {
    is JsonNull -> null
    is JsonPrimitive -> {
        when {
            el.isString -> el.content
            el.booleanOrNull != null -> el.boolean
            el.longOrNull != null -> {
                val l = el.long
                if (el.content.contains(".")) el.double else l
            }

            else -> el.content
        }
    }

    is JsonObject -> el.mapValues { jsonElementToValue(it.value) }
    is JsonArray -> el.map { jsonElementToValue(it) }
}

private fun jsonElementToMap(el: JsonElement): Map<String, Any?> {
    val obj = el as? JsonObject ?: return emptyMap()
    return obj.mapValues { jsonElementToValue(it.value) }
}

private fun typeDefToJson(td: TypeDef): JsonObject {
    val entries = mutableMapOf<String, JsonElement>()
    entries["path"] = JsonPrimitive(td.path)
    td.outputPath?.let { entries["outputPath"] = JsonPrimitive(it) }
    td.type?.let { entries["type"] = JsonPrimitive(it) }
    td.actionTitle?.let { entries["actionTitle"] = JsonPrimitive(it) }
    td.options?.let { entries["options"] = JsonArray(it.map { o -> JsonPrimitive(o) }) }
    td.min?.let { entries["min"] = JsonPrimitive(it) }
    td.max?.let { entries["max"] = JsonPrimitive(it) }
    td.step?.let { entries["step"] = JsonPrimitive(it) }
    td.readOnly?.let { entries["readOnly"] = JsonPrimitive(it) }
    td.priority?.let { entries["priority"] = JsonPrimitive(it) }
    td.units?.let { entries["units"] = JsonPrimitive(it) }
    td.unitsHtml?.let { entries["unitsHtml"] = JsonPrimitive(it) }
    return JsonObject(entries)
}