Skip to content

BoxOutputLog.kt

src/jvmMain/kotlin/afield/app/diagnostics/BoxOutputLog.kt · 233 lines

package afield.app.diagnostics

A bounded server-side record of each box's printed text, errors, display(...) channels and execution verdicts, readable without a websocket.

Readers take BoxOutputLog.mark before acting and read BoxOutputLog.since afterwards to see what one run produced. The bounds and the feed points are described on BoxOutputLog.

import afield.app.protocol._send
import afield.data.table.div
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.ExecutionTrace
import afield.runtime.execution.dispatchExecutionOutput
import afield.runtime.execution.plus
import afield.runtime.lifecycle.forever
import afield.runtime.lifecycle.frames
import afield.runtime.lifecycle.loop
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with
import afield.ui.output.history

import afield.app.protocol.ExecutionReport
import afield.app.protocol.Message
import afield.app.protocol.Output
import afield.app.protocol.payload
import afield.audio.synth.*
import afield.data.table.get
import afield.document.graph.Box
import afield.document.graph.box
import afield.document.workspace.Entry
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.ui.output.outputDocument
import afield.ui.output.templates.filter
import java.util.concurrent.atomic.AtomicLong

object BoxOutputLog

A bounded, server-side record of what each box has said: printed text, error diagnostics, display(...) channels, and execution verdicts.

Why this exists

Until now a box's output only existed on the wire. println left through dispatchExecutionOutput as an output message to whichever websocket asked for the run, and display left as a set-output broadcast; nothing on the server kept either. So the only way to learn what a box printed was to be a websocket client — which is why every external agent drives Field through tools/field_backdoor.py, and why Field's own agent could not "run a box and read what it printed" at all.

This log is fed at the three places every such message originates — dispatchExecutionOutput, the document broadcast _send(message, document) (for set-output), and ExecutionTrace.finish (for verdicts) — so it sees browser-started runs, backdoor runs and in-Field-agent runs alike, once each, whether or not any browser is attached.

Shape

Entries carry a global, monotonically increasing Entry.seq. A reader takes mark before it acts and asks for since that mark afterwards; this is how the agent tools report "what this run printed" without racing a box that prints forever.

Bounds: maxEntriesPerBox per box (oldest dropped), maxTextChars per entry, maxBoxes boxes (least-recently-written dropped), and maxTotalChars across everything (oldest entries of the least-recently-written boxes dropped first). A box in a tight println loop therefore costs a fixed amount of memory, and a reader that falls behind sees Page.dropped rather than silently missing lines.

A display replaces its channel on screen, so a new display entry replaces the box's previous entry for that channel here too. Without that, one animated box re-displaying 4 KB of HTML per frame filled its whole ring with frames nobody will read, and the per-box bounds alone allowed 400 × 4000 chars × 4096 boxes — gigabytes — before maxTotalChars existed (review, 2026-09-29). A deleted box's history is dropped by the -delete-box action.

object BoxOutputLog {

data class Entry

One thing a box said. kind is out, err, display, or verdict.

    data class Entry(
        val seq: Long,
        val uid: String,
        val kind: String,
        val text: String,
        val channel: String = "",
        val property: String = "",
        val line: Int = 0,
        val document: String = "",
        val atMillis: Long = System.currentTimeMillis(),
        /** For `verdict` entries: whether setup completed. */
        val success: Boolean? = null,
    )

data class Page(val entries: List<Entry>, val marker: Long, val dropped: Boolean)

const val maxEntriesPerBox = 400

const val maxTextChars = 4000

const val maxBoxes = 4096

const val maxTotalChars = 16_000_000L

About 32 MB of UTF-16 across all boxes.

    private val sequence = AtomicLong(0)
    private val lock = Any()
    private var totalChars = 0L

Per box, the newest seq ever dropped by a bound — how since knows a reader missed something.

    private val droppedUpTo = HashMap<String, Long>()
    private val byBox = object : LinkedHashMap<String, ArrayDeque<Entry>>(64, 0.75f, true) {
        override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ArrayDeque<Entry>>?): Boolean {
            if (size <= maxBoxes || eldest == null) return false
            // Evicting a whole box must release its characters too, or the global cap drifts
            // upward forever (review, 2026-09-29).
            totalChars -= eldest.value.sumOf { it.text.length.toLong() }
            droppedUpTo.remove(eldest.key)
            return true
        }
    }

    private fun noteDropped(entry: Entry) {
        droppedUpTo[key(entry.uid,entry.document)] = maxOf(droppedUpTo[key(entry.uid,entry.document)] ?: 0L, entry.seq)
    }

fun mark(): Long = sequence.get()

The current sequence number; everything recorded afterwards has a larger Entry.seq.

fun record

    fun record(
        uid: String,
        kind: String,
        text: String,
        channel: String = "",
        property: String = "",
        line: Int = 0,
        success: Boolean? = null,
        document: String = "",
    ) {
        if (uid.isBlank()) return
        val clipped = if (text.length <= maxTextChars) text else text.take(maxTextChars) + "…"
        synchronized(lock) {
            val entry = Entry(sequence.incrementAndGet(), uid, kind, clipped, channel, property, line, document=document, success = success)
            val storageKey=key(uid,document)
            val deque = byBox.getOrPut(storageKey) { ArrayDeque() }
            if (kind == "display") {
                val it = deque.iterator()
                while (it.hasNext()) {
                    val old = it.next()
                    if (old.kind == "display" && old.channel == channel) { totalChars -= old.text.length; it.remove() }
                }
            }
            deque.addLast(entry)
            totalChars += clipped.length
            while (deque.size > maxEntriesPerBox) {
                val dropped = deque.removeFirst()
                totalChars -= dropped.text.length
                noteDropped(dropped)
            }
            while (totalChars > maxTotalChars) {
                val eldest = byBox.entries.firstOrNull() ?: break
                val dropped = eldest.value.removeFirstOrNull()
                if (dropped == null) { byBox.remove(eldest.key); continue }
                totalChars -= dropped.text.length
                noteDropped(dropped)
                // Keep the (now empty) deque so the drop marker still answers for this box.
            }
        }
    }

Entries for uid with seq > marker, oldest first.

    private fun key(uid: String, document: String) = if(document.isBlank()) uid else document+"\u0000"+uid

fun since(box: Box, marker: Long, limit: Int = maxEntriesPerBox)

    fun since(box: Box, marker: Long, limit: Int = maxEntriesPerBox) = since(box.id,marker,limit,outputDocument(box))

fun since(uid: String, marker: Long, limit: Int = maxEntriesPerBox, document: String = ""): Page

    fun since(uid: String, marker: Long, limit: Int = maxEntriesPerBox, document: String = ""): Page = synchronized(lock) {
        val storageKey=key(uid,document)
        val deque = byBox[storageKey] ?: return@synchronized Page(emptyList(), mark(), false)
        val all = deque.filter { it.seq > marker }
        // Something this reader had not yet seen was dropped by a bound (per-box or global).
        val dropped = (droppedUpTo[storageKey] ?: 0L) > marker
        val page = if (all.size <= limit) all else all.takeLast(limit)
        Page(page, page.lastOrNull()?.seq ?: marker, dropped || all.size > limit)
    }

fun clear(uid: String, document: String = "")

Forget a box's history (tests, and a deleted box).

    fun clear(uid: String, document: String = "") = synchronized(lock) {
        val storageKey=key(uid,document)
        byBox.remove(storageKey)?.let { removed -> totalChars -= removed.sumOf { it.text.length.toLong() } }
        droppedUpTo.remove(storageKey)
        Unit
    }

fun heldChars(): Long = synchronized(lock) { totalChars }

Characters currently held, for tests and diagnostics.

taps

internal fun observeOutput(output: Output, document: String = "")

    internal fun observeOutput(output: Output, document: String = "") {
        val kind = when (output.kind) {
            "error", "err" -> "err"
            "normal", "" -> "out"
            else -> return // "ok", "clear", annotations: editor bookkeeping, not something the box said
        }
        // Printed non-strings arrive as value-visualizer HTML; its style attributes are full of numbers
        // (`12px`, `1.4`) that would pollute any check or reader looking for the printed values.
        val text = if (kind == "err" || output.html.trimStart().startsWith("<")) htmlToText(output.html) else output.html
        if (text.isBlank()) return
        record(output.uid, kind, text.trimEnd('\n'), property = output.property, line = output.line, document=document)
    }

internal fun observeBroadcast(message: Message, document: String = "")

    internal fun observeBroadcast(message: Message, document: String = "") {
        if (message.name != "set-output") return
        val payload = message.payload as? kotlinx.serialization.json.JsonObject ?: return
        fun s(k: String) = (payload[k] as? kotlinx.serialization.json.JsonPrimitive)?.content.orEmpty()
        if (s("property") != "outputarea") return
        record(s("uid"), "display", s("value"), channel = s("channel").ifBlank { "output" }, document=document)
    }

internal fun observeVerdict(report: ExecutionReport, document: String = "")

    internal fun observeVerdict(report: ExecutionReport, document: String = "") {
        record(
            report.uid, "verdict",
            if (report.success) "${report.mode}: ${report.stage}" else "${report.mode}: failed\n${report.error}",
            property = report.property,
            success = report.success, document=document
        )
    }

internal fun htmlToText(html: String): String

    internal fun htmlToText(html: String): String = html
        .replace(Regex("(?i)<br\\s*/?>"), "\n")
        .replace(Regex("<[^>]+>"), "")
        .replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"")
        .replace("&#39;", "'").replace("&apos;", "'").replace("&amp;", "&")
}