StudyBuildPanel.kt
src/jvmMain/kotlin/afield/study/build/StudyBuildPanel.kt · 362 lines
package and imports — 60 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Workspace")
package afield.study.build
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.m
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.properties.saved
import afield.document.workspace.file
import afield.geometry.spatial.center
import afield.graphics.interaction.pointer
import afield.graphics.render.color
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.max
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.bar
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.close
import afield.runtime.lifecycle.once
import afield.app.commands.action
import afield.audio.synth.*
import afield.data.collections.r
import afield.data.graph.main
import afield.document.workspace.files
import afield.document.workspace.path
import afield.geometry.mesh.summary
import afield.graphics.render.label
import afield.io.osc.main
import afield.math.control.main
import afield.math.statistics.main
import afield.media.image.channel
import afield.media.image.open
import afield.runtime.execution.start
import afield.simulation.automata.main
import afield.study.source.BoxStudies
import afield.ui.output.registerButtonCallback
import afield.runtime.lifecycle.Runloop
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
The build card a box shows for one study build runner: every stage, with its buttons, progress and diagnostics.
showStudyBuilds mounts one of these per output channel. The card drives the real runner through
Field's ordinary button transport, so a stage's Build and Cancel are the same controls any box
output gets, and its HTML is built from the stage list by one function, which is what lets the
panel re-publish only when that HTML has actually changed.
Reading the stages is not free — it re-reads the design's inputs and checks result files — so the
card reads them twice a second only while a build is queued or running, or when StudyBuilds
reports a new revision, and otherwise every five seconds, which is how long an unannounced edit to
the design can take to show up.
internal class StudyBuildPanel(private val binding: BoxStudyBuildBinding, private val channel: String) : AutoCloseable
A small output card over the real runner, using Field's existing button transport. Reading the
stages re-reads the design's inputs and checks result files, so the card reads them every
STUDY_BUILD_POLL_MS only while a build is queued or running, or when the runner's StudyBuilds.revision
moves; otherwise every STUDY_BUILD_IDLE_REFRESH_MS, which is how soon an unannounced design edit shows.
internal class StudyBuildPanel(private val binding: BoxStudyBuildBinding, private val channel: String) : AutoCloseable {
private val prefix = "study-build-${UUID.randomUUID()}"
private val executor = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "study-build-panel").apply { isDaemon = true } }
@Volatile private var shown = emptyList<StudyStageState>()
private var findings = emptyList<StudyBuildFinding>()
@Volatile private var error: String? = null
@Volatile private var closed = false
private var lastHtml = ""
private var seen: List<Long>? = null
private var lastRefresh = 0L
private var building = false
init
body — 52 lines, from line 95
init {
val initial = binding.builds.stages()
for (stage in initial) {
registerButtonCallback("$prefix-${stage.id}-build") { down -> if (down && !closed) executor.execute {
try {
val state = shown.first { it.id == stage.id }
binding.builds.start(stage.id, checkNotNull(state.fingerprint) { state.reason.orEmpty() })
error = null
} catch (e: Exception) { error = e.message ?: "Could not start this build" }
refresh()
} }
registerButtonCallback("$prefix-${stage.id}-cancel") { down -> if (down && !closed) executor.execute {
try {
val job = checkNotNull(shown.first { it.id == stage.id }.latest)
binding.builds.cancel(job.id); error = null
} catch (e: Exception) { error = e.message ?: "Could not cancel this build" }
refresh()
} }
}
// Publish the first card during showStudyBuilds(), before a short-lived scratch session
// can close. The scheduled poll remains responsible for later worker and input changes.
refresh()
executor.scheduleWithFixedDelay({ poll() }, STUDY_BUILD_POLL_MS, STUDY_BUILD_POLL_MS, TimeUnit.MILLISECONDS)
}
private fun poll() {
if (closed) return
val revision = runCatching { binding.builds.revision() }.getOrNull()
if (studyBuildPanelDue(building, revision, seen, System.nanoTime() - lastRefresh)) refresh()
}
private fun refresh() {
if (closed) return
// Read before the stages, so a change recorded during this read brings the next poll back.
seen = runCatching { binding.builds.revision() }.getOrNull()
lastRefresh = System.nanoTime()
val html = try {
shown = binding.builds.stages()
building = shown.any { it.latest?.status in setOf(StudyBuildStatus.QUEUED, StudyBuildStatus.RUNNING) }
val runs = shown.mapNotNull { it.latest?.id }.toSet()
val read = { studyBuildFindings(binding.owner.inside.boxes.values + binding.owner.inside.root, runs) }
findings = if (Runloop.isMainThread) read() else runBlocking { withContext(Runloop.runLoop.main) { read() } }
studyBuildCardHtml(prefix, binding.name, shown, findings, error, exclusive = binding.builds.exclusive, button = ::button)
}
catch (e: Exception) { "<div class=\"study-build-error\" style=\"color:#ff6969;user-select:text\">${studyBuildEscape(e.message ?: "Build runner unavailable")}</div>" }
if (html != lastHtml && !closed) { lastHtml = html; BoxStudies.show(binding.owner, channel, html) }
}
private fun button(stage: String, action: String, label: String, important: Boolean): String {
val id = "$prefix-$stage-$action"
return """<span class="field-button" data-control-id="$id"><button id="$id" type="button" class="field-button-hit ${if (important) "important" else ""}" aria-pressed="false">$label</button></span>"""
}
override fun close() { closed = true; executor.shutdownNow() }
internal const val STUDY_BUILD_POLL_MS = 500L
internal const val STUDY_BUILD_IDLE_REFRESH_MS = 5_000L
internal fun studyBuildPanelDue(building: Boolean, revision: List<Long>?, seen: List<Long>?, sinceRefreshNanos: Long): Boolean
Whether a StudyBuildPanel poll reads the stages: while building, when the runner's revision
differs from the one last seen (or cannot be read), or once sinceRefreshNanos reaches the idle interval.
internal fun studyBuildPanelDue(building: Boolean, revision: List<Long>?, seen: List<Long>?, sinceRefreshNanos: Long): Boolean =
building || revision == null || revision != seen || sinceRefreshNanos >= STUDY_BUILD_IDLE_REFRESH_MS * 1_000_000
internal typealias StudyBuildButton
Renders one stage's Field button; StudyBuildPanel passes the buttons it registered callbacks for.
internal typealias StudyBuildButton = (stage: String, action: String, label: String, important: Boolean) -> String
internal fun studyBuildCardHtml(prefix: String, runner: String, stages: List<StudyStageState>,
The whole card: one section per stage under a style block scoped to prefix. In an exclusive
runner, a stage that could build waits while another stage's build is running.
internal fun studyBuildCardHtml(prefix: String, runner: String, stages: List<StudyStageState>,
findings: List<StudyBuildFinding>, error: String? = null, exclusive: Boolean = false,
button: StudyBuildButton = { _, _, _, _ -> "" }): String {
val busy = stages.firstOrNull { it.latest?.status in setOf(StudyBuildStatus.QUEUED, StudyBuildStatus.RUNNING) }?.label?.takeIf { exclusive }
val cards = stages.joinToString("") { studyBuildStageHtml(it, stages, findings, busy, button) }
return """
<style>
.$prefix{background:#000;color:#ddd;padding:16px;border-radius:12px;font:14px/1.45 Iosevka,monospace;max-width:640px;box-sizing:border-box}
.$prefix section+section{margin-top:24px}.$prefix .study-build-title{display:flex;align-items:center;gap:12px;user-select:none}
.$prefix .study-build-state{color:#999;font-size:inherit;margin-left:auto}.$prefix p{margin:8px 0 0;user-select:text;white-space:pre-wrap;overflow-wrap:anywhere}
.$prefix .study-build-findings{margin-top:12px;padding:12px;background:#141414;border-radius:8px}.$prefix .study-build-findings a{display:block;margin-top:10px;color:#62d7ff;text-decoration:none}.$prefix .study-build-findings a:hover{text-decoration:underline}
.$prefix .study-build-findings .study-build-finding-error{color:#ff6969}.$prefix .study-build-findings .study-build-finding-warning{color:#f5b748}.$prefix .study-build-findings .study-build-finding-resolved{color:#999}
.$prefix .study-build-error{color:#ff6969}.$prefix .study-build-muted{color:#999}.$prefix progress{display:block;width:100%;height:5px;margin-top:12px;accent-color:#55d4f2;border:0;border-radius:4px;overflow:hidden}
.$prefix .study-build-geometry-view a{color:#ff6969;text-decoration:none}.$prefix .study-build-geometry-view a:hover{text-decoration:underline}
.$prefix .study-build-result-notes{padding:12px;background:#171717;border-radius:8px;margin-top:12px}.$prefix .study-build-result-notes p:first-child{margin-top:0}
.$prefix .study-build-elapsed{color:#999}
.$prefix .study-build-diagnostic{margin-top:12px;background:#171717;padding:12px;border-radius:8px}.$prefix .study-build-diagnostic summary{color:#ff6969;cursor:pointer;user-select:none}.$prefix .study-build-diagnostic pre{font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;user-select:text}
.$prefix .study-build-diagnostic.study-build-diagnostic-earlier summary{color:#999}
.$prefix progress::-webkit-progress-bar{background:#262626}.$prefix progress::-webkit-progress-value{background:#55d4f2}
.$prefix .field-button{display:inline-flex;margin:0;padding:0;background:none;border:0}
.$prefix button.field-button-hit{font:inherit;text-transform:none;letter-spacing:normal;padding:8px 14px;border:0;border-radius:9px;outline:none;box-shadow:none;background:#262626;color:#ddd;cursor:pointer;user-select:none}
.$prefix button.important{background:#55d4f2;color:#000}.$prefix button:focus-visible{outline:none;box-shadow:none}.$prefix button:hover{filter:brightness(1.12)}
</style><div class="$prefix" data-study-builds="${studyBuildEscape(runner)}" data-afield-intrinsic-width="640">$cards${error?.let { "<p class=\"study-build-error\">${studyBuildEscape(it)}</p>" }.orEmpty()}</div>
""".trimIndent()
}
internal fun studyBuildUsedEarlierInputs(state: StudyStageState, job: StudyBuildRecord): Boolean
Whether job ran against inputs other than state's current ones, so its failure and findings
describe an earlier design. A stage that cannot be fingerprinted now (missing input, dependency
not current) counts as changed. The card and the agent's build tools both ask this.
internal fun studyBuildUsedEarlierInputs(state: StudyStageState, job: StudyBuildRecord): Boolean =
!job.ranAgainst(state.fingerprint)
internal fun studyBuildLatestUsedEarlierInputs(state: StudyStageState): Boolean
studyBuildUsedEarlierInputs for the stage's latest attempt, the one the card shows.
internal fun studyBuildLatestUsedEarlierInputs(state: StudyStageState): Boolean =
state.latest != null && studyBuildUsedEarlierInputs(state, state.latest)
internal fun studyBuildStageHtml(state: StudyStageState, stages: List<StudyStageState>,
One stage's section of the card. stages names blocking stages by label. busyWith is the label
of the stage an exclusive runner is building, when that is another stage.
body — 51 lines, from line 210
internal fun studyBuildStageHtml(state: StudyStageState, stages: List<StudyStageState>,
findings: List<StudyBuildFinding>, busyWith: String? = null, button: StudyBuildButton = { _, _, _, _ -> "" }): String {
val latest = state.latest
val running = latest?.status in setOf(StudyBuildStatus.QUEUED, StudyBuildStatus.RUNNING)
val earlier = studyBuildLatestUsedEarlierInputs(state)
val failure = latest?.problem?.takeIf { latest.status == StudyBuildStatus.FAILED && !earlier }
val label = when {
running && latest!!.cancelRequested -> "Cancelling"
running && earlier -> "Building an earlier design"
running -> "Building"
failure != null -> "Build failed"
state.current != null -> studyBuildCurrentLabel(state.resultNotes)
state.blockedBy.isNotEmpty() -> "Waiting for an earlier stage"
state.inputHelp != null -> state.inputHelp.label
state.reason?.startsWith("Missing input") == true -> "Needs input"
latest != null -> "Needs rebuilding"
else -> "Ready to build"
}
val message = when {
running && earlier -> "The design changed. This result will be retained for inspection."
running -> latest!!.message
state.blockedBy.isNotEmpty() -> "Complete ${state.blockedBy.joinToString { id -> stages.firstOrNull { it.id == id }?.label ?: id }} first."
state.inputHelp != null -> state.inputHelp.message
else -> failure?.message ?: state.reason.orEmpty()
}
val advice = failure?.suggestions?.joinToString(" ").orEmpty()
val request = latest?.request?.takeIf { it.isNotEmpty() }?.let { asked ->
"<p class=\"study-build-muted\">Request: ${studyBuildEscape(asked.entries.joinToString { (key, value) -> "$key = $value" })}</p>"
}.orEmpty() + latest?.resumedFrom.orEmpty().joinToString("") { resumed ->
val files = resumed.files.map { it.path }.let { if (it.size > 3) it.take(3) + "${it.size - 3} more" else it }
"<p class=\"study-build-muted\">Continued from attempt ${studyBuildEscape(resumed.jobId.take(8))}: ${studyBuildEscape(files.joinToString())}</p>"
}
val geometry = failure?.let(::studyBuildGeometryViewHtml).orEmpty()
val action = when {
running && !latest!!.cancelRequested -> button(state.id, "cancel", "Cancel", false)
!running && state.fingerprint != null && busyWith == null -> button(state.id, "build", if (state.current != null) "Rebuild" else "Build", true)
else -> ""
}
val turn = if (!running && state.fingerprint != null && busyWith != null)
"<p class=\"study-build-muted\">One build at a time: this can start when ${studyBuildEscape(busyWith)} finishes.</p>" else ""
val progress = if (running) """<progress max="1" value="${latest!!.progress}" aria-label="${studyBuildEscape(state.label)} progress"></progress><p class="study-build-elapsed">${studyBuildElapsedText(latest)}</p>""" else ""
val prior = if (failure != null && state.current != null) "<p>Previous checked result is still current.</p>" else ""
// A failed attempt's notes sit with its failure; the current result's, if any, follow under their own heading.
val failureNotes = if (failure != null) studyBuildResultNotesHtml(state.failureNotes) else ""
val resultNotes = studyBuildResultNotesHtml(state.resultNotes,
state.current != null && latest?.id != state.current.id)
val reviewLinks = studyBuildFindingsHtml(state, findings)
val hasReview = !earlier && findings.any { it.runId == latest?.id }
val diagnostic = latest?.takeIf { it.problem != null }?.let { studyBuildDiagnosticHtml(it, hasReview, currentInputs = !earlier) }.orEmpty()
return """<section data-stage="${state.id}"><div class="study-build-title"><span>${studyBuildEscape(state.label)}</span><span class="study-build-state">$label</span>$action</div>$progress<p class="${if (failure != null) "study-build-error" else ""}">${studyBuildEscape(message)}</p>$request$turn${if (advice.isNotEmpty()) "<p class=\"study-build-error\">${studyBuildEscape(advice)}</p>" else ""}$geometry$failureNotes$prior$resultNotes$reviewLinks$diagnostic</section>"""
}
internal fun studyBuildFindingsHtml(state: StudyStageState, findings: List<StudyBuildFinding>): String
Review findings of the stage's latest attempt, coloured by kind: open errors red, open warnings amber, decisions cyan. Findings of an attempt against earlier inputs are not open errors in the current design, so they collapse to one muted line until a rebuild rechecks them.
internal fun studyBuildFindingsHtml(state: StudyStageState, findings: List<StudyBuildFinding>): String {
val reviews = findings.filter { it.runId == state.latest?.id }
if (reviews.isEmpty()) return ""
if (studyBuildLatestUsedEarlierInputs(state)) {
val count = if (reviews.size == 1) "1 finding" else "${reviews.size} findings"
return "<p class=\"study-build-muted\">$count from an attempt with earlier inputs; rebuild to recheck.</p>"
}
val open = reviews.count { it.status != "resolved" }
return "<div class=\"study-build-findings\"><p>Findings for this attempt · $open open · ${reviews.size - open} closed</p>" +
reviews.joinToString("") { finding ->
val resolved = finding.status == "resolved"
val kind = when (finding.kind) { "error" -> "Error"; "warning" -> "Needs inspection"; else -> "Decision needed" }
val tone = when {
resolved -> "study-build-finding-resolved"
finding.kind == "error" -> "study-build-finding-error"
finding.kind == "warning" -> "study-build-finding-warning"
else -> "study-build-finding-question"
}
val notes = listOfNotNull("resolved".takeIf { resolved },
if (!finding.live) "run the review source to open" else "earlier geometry".takeIf { finding.geometryStale == true })
val labelText = studyBuildEscape((listOf("$kind · ${finding.text}") + notes).joinToString(" · "))
if (finding.live) "<a class=\"$tone\" href=\"#box:${studyBuildEscape(finding.outputBox)}\" data-box-uid=\"${studyBuildEscape(finding.outputBox)}\" data-review-view=\"${studyBuildEscape(finding.viewId)}\" data-review-note=\"${studyBuildEscape(finding.noteId)}\">$labelText</a>"
else "<p class=\"study-build-state $tone\">$labelText</p>"
} + "</div>"
}
internal fun studyBuildCurrentLabel(notes: List<StudyBuildNotice>): String
internal fun studyBuildCurrentLabel(notes: List<StudyBuildNotice>): String = when {
notes.any { it.kind == StudyBuildNoticeKind.ERROR } -> "Current · Needs correction"
notes.any { it.kind == StudyBuildNoticeKind.WARNING } -> "Current · Needs review"
else -> "Current"
}
internal fun studyBuildElapsedText(job: StudyBuildRecord, now: java.time.Instant = java.time.Instant.now()): String
internal fun studyBuildElapsedText(job: StudyBuildRecord, now: java.time.Instant = java.time.Instant.now()): String {
val seconds = runCatching { java.time.Duration.between(java.time.Instant.parse(job.created), now).seconds }.getOrDefault(0).coerceAtLeast(0)
return "Elapsed ${seconds / 60}m ${seconds % 60}s · progress reports completed work, not time remaining"
}
internal fun studyBuildResultNotesHtml(notes: List<StudyBuildNotice>, previous: Boolean = false): String
internal fun studyBuildResultNotesHtml(notes: List<StudyBuildNotice>, previous: Boolean = false): String {
if (notes.isEmpty()) return ""
val heading = if (previous) "<p>Previous current result</p>" else ""
return "<div class=\"study-build-result-notes\">$heading" + notes.joinToString("") {
val kind = when (it.kind) { StudyBuildNoticeKind.INFO -> ""; StudyBuildNoticeKind.WARNING -> "Needs review: "; StudyBuildNoticeKind.ERROR -> "Needs correction: " }
"<p${if (it.kind == StudyBuildNoticeKind.ERROR) " class=\"study-build-error\"" else ""}>${studyBuildEscape(kind + it.message)}</p>"
} + "</div>"
}
internal data class StudyBuildGeometryView(val box: String, val caption: String?, val viewId: String?)
Where a failed attempt draws its own failure: problem.details["geometryView"], a map whose
"box" is the uid of the box whose output shows the failing geometry in red, with an optional
"caption" describing what is drawn and an optional "viewId" naming the view in that output.
internal data class StudyBuildGeometryView(val box: String, val caption: String?, val viewId: String?)
internal fun studyBuildGeometryView(problem: StudyBuildProblem): StudyBuildGeometryView?
The attempt's StudyBuildGeometryView, or null when it named none or its "box" is not a uid string.
internal fun studyBuildGeometryView(problem: StudyBuildProblem): StudyBuildGeometryView? {
val view = problem.details["geometryView"] as? Map<*, *> ?: return null
fun text(key: String) = (view[key] as? String)?.trim()?.takeIf { it.isNotEmpty() }
return StudyBuildGeometryView(text("box") ?: return null, text("caption"), text("viewId"))
}
internal fun studyBuildGeometryViewHtml(problem: StudyBuildProblem): String
A red link that opens the output of the box showing where this attempt fails, or "" when the attempt
named none. data-box-reveal="output" makes the output area's link handler reveal that box's output
rather than open its code.
internal fun studyBuildGeometryViewHtml(problem: StudyBuildProblem): String {
val view = studyBuildGeometryView(problem) ?: return ""
val uid = studyBuildEscape(view.box)
val viewAttribute = view.viewId?.let { " data-review-view=\"${studyBuildEscape(it)}\"" }.orEmpty()
val caption = view.caption?.let { " · ${studyBuildEscape(it)}" }.orEmpty()
return "<p class=\"study-build-geometry-view\"><a href=\"#box:$uid\" data-box-uid=\"$uid\" data-box-reveal=\"output\"$viewAttribute>Show where it fails…</a>$caption</p>"
}
internal fun studyBuildDiagnosticHtml(job: StudyBuildRecord, hasReview: Boolean, currentInputs: Boolean = true): String
Retained diagnostics are available even when a recipe supplied no spatial finding. With
currentInputs false the attempt ran against an earlier design, so the panel is muted and says so.
internal fun studyBuildDiagnosticHtml(job: StudyBuildRecord, hasReview: Boolean, currentInputs: Boolean = true): String {
val problem = job.problem ?: return ""
val details = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writerWithDefaultPrettyPrinter()
.writeValueAsString(mapOf("attempt" to job.id, "stage" to job.stage, "status" to job.status,
"updated" to job.updated, "problem" to problem))
val location = when {
!currentInputs -> "This attempt ran against earlier inputs, so its failure may no longer apply; rebuild to recheck. Its saved diagnostic is below."
studyBuildGeometryView(problem) != null -> "The failing geometry is shown in red in the output of the box that “Show where it fails” opens." +
(if (hasReview) " The finding links above point at individual problems." else "") + " Its saved diagnostic is below."
hasReview -> "Use the finding links above to inspect the affected geometry."
else -> "This attempt supplied no geometry location to highlight. Its saved diagnostic is below."
}
val (kind, summary) = if (currentInputs) "study-build-diagnostic" to "Inspect failure details"
else "study-build-diagnostic study-build-diagnostic-earlier" to "Inspect the earlier attempt's failure"
return """<details class="$kind" data-build-attempt="${studyBuildEscape(job.id)}"><summary>$summary</summary><p>${studyBuildEscape(location)}</p><pre>${studyBuildEscape(details)}</pre></details>"""
}
private fun studyBuildEscape(text: String): String = text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)