Skip to content

AgentTranscriptMaterializer.kt

src/jvmMain/kotlin/afield/examples/AgentTranscriptMaterializer.kt · 446 lines

package afield.examples

import afield.document.graph.Box
import afield.document.graph.METAROOT_ID
import afield.tools.automation.OperationTranscript
import afield.document.persistence.SavingContext
import afield.app.server.SessionInformation
import afield.document.graph.isTrue
import afield.app.server.thisSessionInfo
import org.yaml.snakeyaml.Yaml
import java.io.File
import java.nio.file.Files
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.system.exitProcess

FIELD_TEST id: examples.agent-transcripts.materialize runner: jvm-main entrypoint: afield.examples.AgentTranscriptMaterializer tags: [smoke, agents, transcript, docs, welcome, verification] timeoutSec: 300 expect: exitCode: 0 stdoutContains: - "AgentTranscriptMaterializer ok"

object AgentTranscriptMaterializer

Turns every agent transcript under dev_docs/agents/transcripts/ into a loadable Field document — a <out>/<name>/metadata.edn scene an editor/welcome can open, run and edit. This is how the transcript corpus becomes both the docs gallery workspace and the welcome "starting points": one source of truth (the yaml), documents derived at build time.

The transcript yaml is the single source of truth and stays runnable as-is — this harness must never require a save op inside a transcript. It performs the save itself, out of band.

How it builds a document without a GPU: it applies only the structural ops (create/connect/edit/set/move — the same DemoTranscriptExecSmokeTest GRAPH_VERBS set), never launch/execute. That yields the full box graph (topology + code + shader properties) with no Window/Renderer and no run state — which is exactly what a pristine "starting point" should be: open it, then run it. It then persists the live boxes through the identical codepath the save transcript op uses (SavingContext.save over the non-root, non-tombstoned boxes).

Output root resolution (in order): 1. -Dafield.agentTranscripts.out=<dir> (the docs build passes the committed asset dir here); 2. first CLI arg; 3. a throwaway temp dir — the default, so the CI/FIELD_TEST run verifies real correctness without ever writing committed assets (see the "no committed writes" test invariant).

The FIELD_TEST run uses default (3): it materializes to temp and asserts each transcript yields a metadata.edn carrying the expected number of boxes.

object AgentTranscriptMaterializer {

Repo-relative location of the committed starting-point bundle (must match dir.kt's constant).

    private const val STARTING_POINTS_REL = "public/docs/assets/agent_transcripts"

internal val EXCLUDE

Transcripts that are NOT self-contained scenes, so they make broken starting points and are not materialized. afield_four_traditional_triangle is a child box that gets its Renderer from an external agent_graphics_context parent (the graphics-topology rule) — alone it has no renderer. graphics_renderer_launch is just that renderer parent with nothing to draw. Both stay in the corpus for agents composing scenes (and in DemoTranscriptExecSmokeTest); they are only excluded from the welcome/gallery starting points.

Two transcripts were excluded here until 2026-09-13 and are not any more, which is worth recording because both reasons were the wrong shape. tone_synth_sequence was held back because WebAudio needs a user gesture before it makes sound — but the scene is otherwise completely self-contained (it embeds the head in the document and the Tone.js it uses is vendored), and "you have to click it once" is a thing a starting point can say, not a reason to withhold the code. remotear_scene_motion was held back because it referred to an external /abs/assets/robot.glb that no checkout has; it now loads the committed CC0 Khronos ToyCar over the /sample-assets mount, so there is nothing left to half-load.

internal rather than private because afield.tools.docs.AgentTranscriptIndexGuard reads it: the public index page must carry an Open ↗ starting-point link for a transcript exactly when that transcript is materialized, so this set is also the list of entries that must not offer one.

    internal val EXCLUDE = setOf(
        "afield_four_traditional_triangle",
        // A macOS receiver integration smoke, not a user-facing starting scene.
        "external_browser_surface_macos_smoke",
        // The Linux/dma-buf receiver integration smoke, likewise.
        "external_browser_surface_linux_smoke",
        // The macOS IOSurface round trip (streamer ring → CPU → browser receiver) on either driver:
        // an integration probe that needs the field-encode helper, not a starting scene.
        "iosurface_ring_roundtrip_smoke",
        "graphics_renderer_launch",
        // Needs both things this set is for: a renderer from a box it does not contain, and a
        // directory of images that is the reader's to supply. Opened on its own it would build a
        // wall of nothing.
        "mega_texture_wall",
    )

internal val GRAPH_VERBS

Ops that build the box graph without compiling/running any box Kotlin (mirrors the smoke test).

    internal val GRAPH_VERBS = setOf(
        "create", "new-box", "box", "connect", "disconnect", "edit", "set", "set-string", "move", "frame", "set-rect", "wait",
    )

fun main(args: Array<String>)

body — 108 lines, from line 102
    @JvmStatic
    fun main(args: Array<String>) {
        val transcriptsDir = locateTranscriptsDir()
        val explicitOut = System.getProperty("afield.agentTranscripts.out")?.isNotBlank() == true ||
            args.firstOrNull()?.isNotBlank() == true
        val outRoot = resolveOutRoot(args)
        outRoot.mkdirs()
        println("AgentTranscriptMaterializer: transcripts = ${transcriptsDir.absolutePath}")
        println("AgentTranscriptMaterializer: output root = ${outRoot.absolutePath}")

        startRunloopDaemon()

        val yamls = (transcriptsDir.listFiles { f -> f.isFile && f.extension == "yaml" } ?: emptyArray())
            .sortedBy { it.name }
        require(yamls.isNotEmpty()) { "No transcript yaml files found in ${transcriptsDir.absolutePath}" }

        val failures = mutableListOf<String>()
        var materialized = 0
        for (yaml in yamls) {
            val name = yaml.nameWithoutExtension
            if (name in EXCLUDE) {
                // Not a self-contained scene — don't ship it as a starting point, and make sure any
                // previously-committed copy is removed so the welcome screen stops listing it.
                File(outRoot, name).deleteRecursively()
                println("[materialize] SKIP $name (not self-contained; excluded from starting points)")
                continue
            }
            try {
                val boxes = materializeOne(yaml, name, outRoot)
                val metadata = File(File(outRoot, name), SavingContext.METADATA_EDN)
                when {
                    !metadata.isFile ->
                        failures.add("$name: no ${SavingContext.METADATA_EDN} written to ${metadata.parent}")
                    metadata.readText().isBlank() ->
                        failures.add("$name: ${SavingContext.METADATA_EDN} is empty")
                    boxes == 0 ->
                        failures.add("$name: materialized document has zero boxes")
                    else -> {
                        materialized++
                        println("[materialize] PASS $name — $boxes box(es) -> ${metadata.parentFile.name}/")
                    }
                }
            } catch (t: Throwable) {
                val msg = "$name: harness threw ${t.javaClass.simpleName}: ${t.message}"
                failures.add(msg)
                System.err.println("[error] $msg")
                t.printStackTrace(System.err)
            } finally {
                // Defensive for any future structural op that explicitly reaches a Kotlin engine.
                // Ordinary create ops skip KI initialization in materializeOne: pristine starting
                // points need box state, not hundreds of asynchronously bootstrapped compilers.
                thisSessionInfo.get()?.boxes?.values?.forEach { box ->
                    if (box.id != "root" && box.id != METAROOT_ID) {
                        box.disposeKi()
                    }
                }
                thisSessionInfo.remove()
            }
        }

        // Drift guard: when run with the default (temp) output — i.e. as the CI FIELD_TEST rather
        // than an explicit `--arg <dir>` regeneration — also assert the committed starting-point
        // documents match what the transcripts produce now. This fails the build if someone edits a
        // transcript without regenerating, so the committed bundle can never silently go stale.
        if (failures.isEmpty() && !explicitOut) {
            val committed = File(transcriptsDir.parentFile.parentFile.parentFile, STARTING_POINTS_REL)
            if (committed.isDirectory) {
                println("AgentTranscriptMaterializer: checking committed bundle at ${committed.absolutePath}")
                for (yaml in yamls) {
                    val name = yaml.nameWithoutExtension
                    if (name in EXCLUDE) {
                        if (File(committed, name).exists()) {
                            failures.add("$name: excluded from starting points but still committed at " +
                                "$STARTING_POINTS_REL/$name — delete it (or run " +
                                "`./gradlew regenerateAgentTranscriptStartingPoints`, which removes it).")
                        }
                        continue
                    }
                    val drift = firstDrift(File(outRoot, name), File(committed, name))
                    if (drift != null) {
                        failures.add(
                            "$name: committed starting point is out of date ($drift). Regenerate with " +
                                "`./gradlew regenerateAgentTranscriptStartingPoints` (or " +
                                "`run-jvm-main afield.examples.AgentTranscriptMaterializer --arg <repo>/$STARTING_POINTS_REL`)."
                        )
                    }
                }
            } else {
                // Not "nothing to compare against" — the bundle is committed on purpose (the welcome
                // screen's starting points, the docs gallery and the search index all read it), so its
                // absence in check mode is the largest possible drift, not a reason to pass. Skipping
                // here meant `rm -rf public/docs/assets/agent_transcripts` made checkAgentTranscripts
                // GREEN, which is the one outcome a drift guard must never have.
                failures.add(
                    "no committed starting-point bundle at ${committed.absolutePath} — " +
                        "$STARTING_POINTS_REL is generated but committed, and every transcript's document is " +
                        "missing. Regenerate with `./gradlew regenerateAgentTranscriptStartingPoints`."
                )
            }
        }

        if (failures.isNotEmpty()) {
            System.err.println("AgentTranscriptMaterializer FAILED with ${failures.size} problem(s):")
            failures.forEach { System.err.println("  - $it") }
            exitProcess(1)
        }
        println("AgentTranscriptMaterializer ok — materialized $materialized document(s) into ${outRoot.absolutePath}")
    }

Build yaml's graph structurally, then save its live boxes as a document under outRoot/name.

source — 61 lines, from line 212
    private fun materializeOne(yaml: File, name: String, outRoot: File): Int {
        freshSession(name)
        val ops = loadOps(yaml)
        val graphOps = ops.filter { (it.keys.firstOrNull()?.lowercase() ?: "") in GRAPH_VERBS }

        // Build the box graph with the real apply path — create/connect/edit/move only, no launch.
        val document = currentDocumentDir()
        val macrosDir = File(document, "macros").also { it.mkdirs() }
        val filtered = linkedMapOf<String, Any?>("name" to name, "operations" to graphOps)
        File(macrosDir, "$name.yaml").writeText(Yaml().dump(filtered))
        val build = OperationTranscript.runMacro(
            document.absolutePath,
            name,
            initializeCreatedBoxes = false,
        )
        if (!build.success) {
            // A missing cross-transcript parent (skipped connect) is fine; only note it.
            println("[materialize] $name graph build note: ${build.message}")
        }

        // Persist through the exact codepath the `save` transcript op uses (operation_transcript.kt),
        // but invoked here so the transcript yaml never needs a `save` op of its own.
        val info = thisSessionInfo.get()
        val boxes = boxesForSave(info)
        // This standalone harness does not run the normal @RunsAt startup registry. Add the
        // language-root properties (main language, KotlinJS, FieldML) only while saving documents that actually use them; leaving the
        // global persistence set enlarged would rewrite every unrelated generated starting point.
        val languageProperties = listOf("mainLanguage", "isKotlinJsRoot", "isFieldMLRoot")
        val addedLanguageProperties = languageProperties.filter { property ->
            boxes.any { it.getPropertyRaw(property) != null } && Box.ioPersistentProperties.add(property)
        }
        val target = File(outRoot, name)
        try {
            if (target.exists()) target.deleteRecursively()
            target.mkdirs()
            val latch = CountDownLatch(1)
            SavingContext().save(target.absolutePath, boxes, message = "agent transcript: $name", onComplete = { latch.countDown() })
            check(latch.await(30, TimeUnit.SECONDS)) {
                "save timed out after 30s for $name -> ${target.absolutePath}"
            }

            // `history_metrics.edn` is a per-launch runtime activity log carrying a fresh timestamp on
            // every save. A materialized starting point is pristine (never launched), so drop it — this
            // keeps the committed output byte-stable so regenerating on every docs build causes no churn.
            File(target, "history_metrics.edn").delete()

            // Deterministic normalization of the written files. Box-ki lazy init runs *asynchronously*
            // during the graph build and can inject an empty `preamble` ("") into a box's properties at
            // a timing that varies with JVM warmth — so the saved EDN otherwise differs run-to-run purely
            // by whether that empty key landed before the save snapshot. A blank preamble is regenerated
            // on open anyway (see the `save` op contract), so stripping the exact empty-preamble token
            // makes the committed output byte-stable. Only the empty form is touched; any real preamble
            // content is left intact.
            stripEmptyPreamble(File(target, SavingContext.METADATA_EDN))
            stripEmptyPreamble(File(target, "document.md"))
            writeDocsGuides(docsEntries(yaml), docsRoot(), target)
        } finally {
            Box.ioPersistentProperties.removeAll(addedLanguageProperties)
        }
        return boxes.count { it.id != "root" }
    }

internal fun boxesForSave(info: SessionInformation): Set<Box>

Preserve a transcript's saved drawing and viewport state without adding empty roots elsewhere.

    internal fun boxesForSave(info: SessionInformation): Set<Box> {
        val boxes = info.boxes.values
            .filter { it.id != "root" && it.id != METAROOT_ID && !it.getPropertyRaw("_tombstone").isTrue() }
            .toMutableSet()
        if (listOf("drawing", "panes", "bookmarks").any { info.root.getPropertyRaw(it) != null }) {
            boxes.add(info.root)
        }
        return boxes
    }

internal data class DocsEntry(val page: String, val why: String = "")

One documentation page a starting point points its reader to: page.md[#anchor] and why it matters here.

internal fun docsEntries(yaml: File): List<DocsEntry>

The transcript's top-level docs: list. Each item is a page (drawing_and_notation.md#reading-a-region, relative to public/docs/) or a map with page and an optional one-sentence why.

    internal fun docsEntries(yaml: File): List<DocsEntry> {
        val loaded = Yaml().load<Any?>(yaml.readText()) as? Map<*, *> ?: return emptyList()
        val raw = loaded["docs"] ?: return emptyList()
        require(raw is List<*>) { "${yaml.name}: docs must be a list" }
        return raw.map { item ->
            when (item) {
                is String -> DocsEntry(item.trim())
                is Map<*, *> -> DocsEntry(
                    (item["page"] as? String)?.trim() ?: error("${yaml.name}: a docs entry needs a page"),
                    (item["why"] as? String)?.trim().orEmpty()
                )
                else -> error("${yaml.name}: a docs entry is a page or {page, why}, not $item")
            }
        }
    }

internal fun docsRoot(): File

    internal fun docsRoot(): File = File(locateTranscriptsDir().parentFile.parentFile.parentFile, "public/docs")

internal fun writeDocsGuides(entries: List<DocsEntry>, docsRoot: File, target: File)

Write each entry as a docs-redirect guide (guides/<page>.md with docs: front matter, see afield.app.server.docsRedirect), titled by the page's own first heading so the Guides panel names it the way the site does. A page that does not exist fails the materialization: a renamed page must take its starting-point links with it.

    internal fun writeDocsGuides(entries: List<DocsEntry>, docsRoot: File, target: File) {
        if (entries.isEmpty()) return
        val guides = File(target, "guides").also { it.mkdirs() }
        val used = mutableSetOf<String>()
        for (entry in entries) {
            val pagePath = entry.page.substringBefore('#')
            require(pagePath.endsWith(".md") && !pagePath.split('/').any { it == ".." }) {
                "docs page '${entry.page}' must be a .md path under public/docs"
            }
            val source = File(docsRoot, pagePath)
            require(source.isFile) { "docs page '${entry.page}' does not exist under ${docsRoot.path}" }
            val anchor = entry.page.substringAfter('#', "")
            val headings = source.readLines().filter { Regex("^#{1,6} ").containsMatchIn(it) }
                .map { it.trimStart('#').trim() }
            val pageTitle = source.readLines().firstOrNull { it.startsWith("# ") }?.removePrefix("# ")?.trim()
                ?: pagePath.substringAfterLast('/').removeSuffix(".md").replace('_', ' ')
            // The site's heading ids: lowercase, punctuation dropped, spaces to hyphens.
            fun slug(heading: String) = heading.lowercase().replace(Regex("[^\\p{L}\\p{N}\\s_-]"), "").trim().replace(Regex("\\s+"), "-")
            val section = if (anchor.isEmpty()) null else headings.firstOrNull { slug(it) == anchor }
                ?: throw IllegalArgumentException("docs page '${entry.page}' has no heading with id '$anchor'")
            val title = if (section == null) pageTitle else "$pageTitle · $section"
            var file = pagePath.removeSuffix(".md").replace('/', '-') + if (anchor.isEmpty()) "" else "-$anchor"
            while (!used.add(file)) file += "-2"
            val href = pagePath.removeSuffix(".md") + "/" + if (anchor.isEmpty()) "" else "#$anchor"
            File(guides, "$file.md").writeText(buildString {
                append("---\ndocs: ").append(href).append("\n---\n# ").append(title).append('\n')
                if (entry.why.isNotEmpty()) append('\n').append(entry.why).append('\n')
            })
        }
    }

Compare a freshly-materialized document dir fresh against its committed counterpart and return a short description of the first difference (missing dir/file, extra file, or byte mismatch), or null when they are identical. Used only for the CI drift guard.

    private fun firstDrift(fresh: File, committed: File): String? {
        if (!committed.isDirectory) return "not committed (expected ${committed.name}/)"
        fun rel(root: File, f: File) = f.relativeTo(root).path
        val freshFiles = fresh.walkTopDown().filter { it.isFile }.associateBy { rel(fresh, it) }
        val committedFiles = committed.walkTopDown().filter { it.isFile }.associateBy { rel(committed, it) }
        for ((path, f) in freshFiles.toSortedMap()) {
            val c = committedFiles[path] ?: return "missing committed file $path"
            if (!f.readBytes().contentEquals(c.readBytes())) return "content differs: $path"
        }
        val extra = committedFiles.keys - freshFiles.keys
        if (extra.isNotEmpty()) return "committed has stale file(s): ${extra.sorted().joinToString(", ")}"
        return null
    }

Remove the exact "preamble" "" token (async-injected, non-deterministic) from a saved file.

    private fun stripEmptyPreamble(file: File) {
        if (!file.isFile) return
        val text = file.readText()
        val stripped = text.replace(" \"preamble\" \"\"", "")
        if (stripped != text) file.writeText(stripped)
    }

shared setup (mirrors DemoTranscriptExecSmokeTest)

    private var sessionDocumentDir: File? = null

internal fun currentDocumentDir(): File = sessionDocumentDir ?: error("no session document dir")

internal fun freshSession(name: String): File

    internal fun freshSession(name: String): File {
        thisSessionInfo.remove()
        val workspace = Files.createTempDirectory("afield-materialize-$name").toFile()
        workspace.deleteOnExit()
        val document = File(workspace, "doc").also { it.mkdirs() }
        File(document, SavingContext.METADATA_EDN).writeText("{:version 2 :boxes []}\n")
        val root = afield.document.graph.Box("root")
        val session = SessionInformation(
            filename = document.absolutePath,
            documentName = document.name,
            boxes = ConcurrentHashMap(),
            root = root,
            ephemeral = true,
        )
        root.inside = session
        session.boxes[root.id] = root
        thisSessionInfo.set(session)
        sessionDocumentDir = document
        return document
    }

internal fun loadOps(yaml: File): List<Map<String, Any?>>

    @Suppress("UNCHECKED_CAST")
    internal fun loadOps(yaml: File): List<Map<String, Any?>> {
        val loaded = Yaml().load<Any?>(yaml.readText())
        val raw = when (loaded) {
            is List<*> -> loaded
            is Map<*, *> -> (loaded["operations"] as? List<*>) ?: (loaded["ops"] as? List<*>) ?: emptyList<Any?>()
            else -> emptyList<Any?>()
        }
        return raw.mapNotNull { it as? Map<String, Any?> }
    }

internal fun startRunloopDaemon()

    internal fun startRunloopDaemon() {
        val t = Thread({ afield.runtime.lifecycle.Runloop.runLoop.start() }, "agent-transcript-materializer-runloop")
        t.isDaemon = true
        t.start()
        val deadline = System.currentTimeMillis() + 30_000
        while (afield.runtime.lifecycle.Runloop.runLoop.frame < 1L) {
            check(System.currentTimeMillis() < deadline) { "runloop did not start within 30s" }
            Thread.sleep(20)
        }
        println("AgentTranscriptMaterializer: runloop up (frame=${afield.runtime.lifecycle.Runloop.runLoop.frame})")
    }

    private fun resolveOutRoot(args: Array<String>): File {
        System.getProperty("afield.agentTranscripts.out")?.takeIf { it.isNotBlank() }?.let { return File(it) }
        args.firstOrNull()?.takeIf { it.isNotBlank() }?.let { return File(it) }
        val temp = Files.createTempDirectory("afield-agent-transcripts-out").toFile()
        temp.deleteOnExit()
        return temp
    }

internal fun locateTranscriptsDir(): File

    internal fun locateTranscriptsDir(): File {
        val rel = "dev_docs/agents/transcripts"
        val candidates = buildList {
            System.getProperty("afield.repo")?.let { add(File(it, rel)) }
            var dir: File? = File(System.getProperty("user.dir")).absoluteFile
            while (dir != null) {
                add(File(dir, rel))
                dir = dir.parentFile
            }
        }
        return candidates.firstOrNull { it.isDirectory }
            ?: error("Could not locate $rel from user.dir=${System.getProperty("user.dir")}")
    }
}