From a7104418bf000ce657df4e36ce7562cccceb2b18 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:13:56 +0200 Subject: [PATCH 01/23] feat: add declarative review loop runtime --- loops/review/instructions.md | 5 + loops/review/review.loop | 21 + .../minimal-review-e2e-dom.golden.json | 7 + src/loops/adapters/codex-cli.mjs | 140 +++++++ src/loops/adapters/codex-cli.test.mjs | 126 ++++++ src/loops/adapters/normalized-invocation.mjs | 147 +++++++ .../adapters/normalized-invocation.test.mjs | 74 ++++ src/loops/agents/profile.mjs | 128 +++++++ src/loops/agents/profile.test.mjs | 32 ++ src/loops/assignment/assignment.mjs | 104 +++++ src/loops/assignment/assignment.test.mjs | 299 +++++++++++++++ src/loops/assignment/hazards.mjs | 31 ++ src/loops/assignment/item-metadata.mjs | 98 +++++ src/loops/assignment/resolver.mjs | 59 +++ src/loops/assignment/selectors.mjs | 40 ++ src/loops/assignment/store-worker.mjs | 154 ++++++++ src/loops/assignment/store.mjs | 74 ++++ src/loops/capabilities/capabilities.test.mjs | 80 ++++ src/loops/capabilities/contract.mjs | 76 ++++ src/loops/capabilities/runner.mjs | 63 +++ src/loops/capabilities/snapshot.mjs | 129 +++++++ src/loops/capabilities/trust.mjs | 44 +++ src/loops/completion/completion.mjs | 127 ++++++ src/loops/completion/completion.test.mjs | 128 +++++++ src/loops/config/config.test.mjs | 112 ++++++ src/loops/config/profiles.mjs | 50 +++ src/loops/config/setup.mjs | 39 ++ src/loops/config/store.mjs | 155 ++++++++ src/loops/contracts/agent-result.mjs | 97 +++++ src/loops/contracts/check-result.mjs | 65 ++++ src/loops/contracts/contract.mjs | 84 ++++ src/loops/contracts/contracts.test.mjs | 184 +++++++++ src/loops/contracts/finding.mjs | 51 +++ src/loops/dsl/__fixtures__/review.ir.json | 1 + .../dsl/__fixtures__/review.revisions.json | 1 + src/loops/dsl/canonical.mjs | 56 +++ src/loops/dsl/compile.mjs | 132 +++++++ src/loops/dsl/compile.test.mjs | 277 ++++++++++++++ src/loops/dsl/diagnostics.mjs | 39 ++ src/loops/dsl/frozen.mjs | 80 ++++ src/loops/dsl/grammar.mjs | 114 ++++++ src/loops/dsl/hash.mjs | 39 ++ src/loops/dsl/instructions.mjs | 51 +++ src/loops/dsl/ir-validate.mjs | 58 +++ src/loops/dsl/loop-xml.mjs | 79 ++++ src/loops/dsl/package-read.mjs | 352 +++++++++++++++++ src/loops/dsl/package-read.test.mjs | 311 +++++++++++++++ src/loops/events/projection-events.mjs | 27 ++ src/loops/events/projection-events.test.mjs | 37 ++ src/loops/minimal-review-e2e-fixtures.mjs | 94 +++++ src/loops/minimal-review-e2e.test.mjs | 210 ++++++++++ src/loops/run/binder.mjs | 361 ++++++++++++++++++ src/loops/run/binder.test.mjs | 186 +++++++++ src/loops/run/budgets.mjs | 28 ++ src/loops/run/budgets.test.mjs | 15 + src/loops/run/candidate.mjs | 46 +++ src/loops/run/controller.mjs | 49 +++ src/loops/run/controller.test.mjs | 42 ++ src/loops/run/current-authority.mjs | 56 +++ src/loops/run/current-authority.test.mjs | 28 ++ src/loops/run/launch-authority.test.mjs | 69 ++++ src/loops/run/launch-commit.mjs | 56 +++ src/loops/run/lifecycle.mjs | 76 ++++ src/loops/run/m2-test-fixtures.mjs | 9 + src/loops/run/operation.mjs | 92 +++++ src/loops/run/read-projection.mjs | 112 ++++++ src/loops/run/reviewer-isolation.test.mjs | 20 + src/loops/run/run-admission.mjs | 35 ++ src/loops/run/run-admission.test.mjs | 18 + src/loops/run/run-artifacts.mjs | 95 +++++ src/loops/run/run-claim.mjs | 19 + src/loops/run/run-clock.mjs | 7 + src/loops/run/run-clock.test.mjs | 20 + src/loops/run/run-codec.mjs | 150 ++++++++ src/loops/run/run-created.mjs | 20 + src/loops/run/run-fold.mjs | 15 + src/loops/run/run-fold.test.mjs | 17 + src/loops/run/run-journal.mjs | 51 +++ src/loops/run/run-journal.test.mjs | 50 +++ src/loops/run/run-record.mjs | 103 +++++ src/loops/run/run-ref.mjs | 6 + src/loops/run/run-result.mjs | 16 + src/loops/run/run-store.mjs | 153 ++++++++ src/loops/run/run-store.test.mjs | 117 ++++++ src/loops/run/run-test-fixtures.mjs | 147 +++++++ src/loops/run/runner.mjs | 87 +++++ src/loops/run/runner.test.mjs | 139 +++++++ src/loops/run/state-machine.mjs | 70 ++++ src/loops/run/state-machine.test.mjs | 51 +++ src/loops/view/render.mjs | 106 +++++ src/loops/view/render.test.mjs | 53 +++ 91 files changed, 7771 insertions(+) create mode 100644 loops/review/instructions.md create mode 100644 loops/review/review.loop create mode 100644 src/loops/__fixtures__/minimal-review-e2e-dom.golden.json create mode 100644 src/loops/adapters/codex-cli.mjs create mode 100644 src/loops/adapters/codex-cli.test.mjs create mode 100644 src/loops/adapters/normalized-invocation.mjs create mode 100644 src/loops/adapters/normalized-invocation.test.mjs create mode 100644 src/loops/agents/profile.mjs create mode 100644 src/loops/agents/profile.test.mjs create mode 100644 src/loops/assignment/assignment.mjs create mode 100644 src/loops/assignment/assignment.test.mjs create mode 100644 src/loops/assignment/hazards.mjs create mode 100644 src/loops/assignment/item-metadata.mjs create mode 100644 src/loops/assignment/resolver.mjs create mode 100644 src/loops/assignment/selectors.mjs create mode 100644 src/loops/assignment/store-worker.mjs create mode 100644 src/loops/assignment/store.mjs create mode 100644 src/loops/capabilities/capabilities.test.mjs create mode 100644 src/loops/capabilities/contract.mjs create mode 100644 src/loops/capabilities/runner.mjs create mode 100644 src/loops/capabilities/snapshot.mjs create mode 100644 src/loops/capabilities/trust.mjs create mode 100644 src/loops/completion/completion.mjs create mode 100644 src/loops/completion/completion.test.mjs create mode 100644 src/loops/config/config.test.mjs create mode 100644 src/loops/config/profiles.mjs create mode 100644 src/loops/config/setup.mjs create mode 100644 src/loops/config/store.mjs create mode 100644 src/loops/contracts/agent-result.mjs create mode 100644 src/loops/contracts/check-result.mjs create mode 100644 src/loops/contracts/contract.mjs create mode 100644 src/loops/contracts/contracts.test.mjs create mode 100644 src/loops/contracts/finding.mjs create mode 100644 src/loops/dsl/__fixtures__/review.ir.json create mode 100644 src/loops/dsl/__fixtures__/review.revisions.json create mode 100644 src/loops/dsl/canonical.mjs create mode 100644 src/loops/dsl/compile.mjs create mode 100644 src/loops/dsl/compile.test.mjs create mode 100644 src/loops/dsl/diagnostics.mjs create mode 100644 src/loops/dsl/frozen.mjs create mode 100644 src/loops/dsl/grammar.mjs create mode 100644 src/loops/dsl/hash.mjs create mode 100644 src/loops/dsl/instructions.mjs create mode 100644 src/loops/dsl/ir-validate.mjs create mode 100644 src/loops/dsl/loop-xml.mjs create mode 100644 src/loops/dsl/package-read.mjs create mode 100644 src/loops/dsl/package-read.test.mjs create mode 100644 src/loops/events/projection-events.mjs create mode 100644 src/loops/events/projection-events.test.mjs create mode 100644 src/loops/minimal-review-e2e-fixtures.mjs create mode 100644 src/loops/minimal-review-e2e.test.mjs create mode 100644 src/loops/run/binder.mjs create mode 100644 src/loops/run/binder.test.mjs create mode 100644 src/loops/run/budgets.mjs create mode 100644 src/loops/run/budgets.test.mjs create mode 100644 src/loops/run/candidate.mjs create mode 100644 src/loops/run/controller.mjs create mode 100644 src/loops/run/controller.test.mjs create mode 100644 src/loops/run/current-authority.mjs create mode 100644 src/loops/run/current-authority.test.mjs create mode 100644 src/loops/run/launch-authority.test.mjs create mode 100644 src/loops/run/launch-commit.mjs create mode 100644 src/loops/run/lifecycle.mjs create mode 100644 src/loops/run/m2-test-fixtures.mjs create mode 100644 src/loops/run/operation.mjs create mode 100644 src/loops/run/read-projection.mjs create mode 100644 src/loops/run/reviewer-isolation.test.mjs create mode 100644 src/loops/run/run-admission.mjs create mode 100644 src/loops/run/run-admission.test.mjs create mode 100644 src/loops/run/run-artifacts.mjs create mode 100644 src/loops/run/run-claim.mjs create mode 100644 src/loops/run/run-clock.mjs create mode 100644 src/loops/run/run-clock.test.mjs create mode 100644 src/loops/run/run-codec.mjs create mode 100644 src/loops/run/run-created.mjs create mode 100644 src/loops/run/run-fold.mjs create mode 100644 src/loops/run/run-fold.test.mjs create mode 100644 src/loops/run/run-journal.mjs create mode 100644 src/loops/run/run-journal.test.mjs create mode 100644 src/loops/run/run-record.mjs create mode 100644 src/loops/run/run-ref.mjs create mode 100644 src/loops/run/run-result.mjs create mode 100644 src/loops/run/run-store.mjs create mode 100644 src/loops/run/run-store.test.mjs create mode 100644 src/loops/run/run-test-fixtures.mjs create mode 100644 src/loops/run/runner.mjs create mode 100644 src/loops/run/runner.test.mjs create mode 100644 src/loops/run/state-machine.mjs create mode 100644 src/loops/run/state-machine.test.mjs create mode 100644 src/loops/view/render.mjs create mode 100644 src/loops/view/render.test.mjs diff --git a/loops/review/instructions.md b/loops/review/instructions.md new file mode 100644 index 00000000..23c75f8d --- /dev/null +++ b/loops/review/instructions.md @@ -0,0 +1,5 @@ +## implement +Implement the assigned Burnlist item. Run the required repository checks and report one structured final result. + +## review +Independently review the current candidate without writing. Report approve, reject, or escalate with bounded findings. diff --git a/loops/review/review.loop b/loops/review/review.loop new file mode 100644 index 00000000..fc7b5711 --- /dev/null +++ b/loops/review/review.loop @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json new file mode 100644 index 00000000..5c5f902f --- /dev/null +++ b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json @@ -0,0 +1,7 @@ +[ + {"checkpoint":"needs-human","domBytes":12386,"domSha256":"f1b61d53b386eac2517abb1a8e6f8780c996e884d1a3a9fadb539215f49182dd"}, + {"checkpoint":"paused","domBytes":11628,"domSha256":"6e2e705700cccc3f90713cbea6d4c99796723c35d39af21597ea69e66469c144"}, + {"checkpoint":"repair","domBytes":12494,"domSha256":"4eb6984ea066104d1ff2dc84d80f1e40e4db18c25936a97bcab4527721628844"}, + {"checkpoint":"converged","domBytes":12746,"domSha256":"0f68c9e8e272e2971472ea204e4c6cb5d6595e23c6e4d9a9d3f6a84ed6f79ee6"}, + {"checkpoint":"post-completion","domBytes":9212,"domSha256":"3d84894369b43754d20a2b134d3509fd8460bfd5dc52c0b52127b5bfc0897cf2"} +] diff --git a/src/loops/adapters/codex-cli.mjs b/src/loops/adapters/codex-cli.mjs new file mode 100644 index 00000000..cc7f7846 --- /dev/null +++ b/src/loops/adapters/codex-cli.mjs @@ -0,0 +1,140 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import { TextDecoder } from "node:util"; +import { isAbsolute } from "node:path"; +import { requestedCodexIdentity, validateAgentProfile, validateCodexProbe } from "../agents/profile.mjs"; + +const MAX_OUTPUT_BYTES = 1048576; +const MAX_JSONL_LINE_BYTES = 65536; +const DIRECT_GUARANTEES = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }); + +function fail(message, code = "ELOOP_CODEX_ADAPTER") { throw Object.assign(new Error(`Codex adapter: ${message}`), { code }); } +function text(value, label, maximum = 262144) { + if (typeof value !== "string" || !value || Buffer.byteLength(value) > maximum || value.includes("\0")) fail(`invalid ${label}`); + return value; +} +function invocation(profile, cwd, prompt) { + const requested = requestedCodexIdentity(profile); + if (typeof cwd !== "string" || !isAbsolute(cwd) || /[\0\r\n]/u.test(cwd)) fail("invalid cwd"); + return Object.freeze({ + command: requested.binary, + args: ["exec", "--json", "--ephemeral", "-m", requested.model, "-c", `model_reasoning_effort=${requested.effort}`, "-s", requested.sandbox, "-C", cwd, "--skip-git-repo-check", "--", text(prompt, "prompt")], + requested, + }); +} +function safeToken(value) { return Number.isSafeInteger(value) && value >= 0; } +function usageFrom(events) { + const event = [...events].reverse().find((item) => item.type === "turn.completed" && item.usage && typeof item.usage === "object" && !Array.isArray(item.usage)); + if (!event) return null; + const usage = event.usage; const cached = usage.cached_input_tokens ?? 0; + if (![usage.input_tokens, usage.output_tokens, cached].every(safeToken) || usage.input_tokens > Number.MAX_SAFE_INTEGER - usage.output_tokens) return null; + return Object.freeze({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cachedInputTokens: cached, totalTokens: usage.input_tokens + usage.output_tokens }); +} +function providerReported(events) { + const event = events.find((item) => item.type === "thread.started" && typeof item.thread_id === "string"); + if (!event || !event.thread_id || Buffer.byteLength(event.thread_id) > 512 || /[\0\r\n]/u.test(event.thread_id)) return null; + const optional = (value) => typeof value === "string" && value && Buffer.byteLength(value) <= 512 && !/[\0\r\n]/u.test(value) ? value : null; + return Object.freeze({ model: optional(event.model), sessionId: event.thread_id, version: optional(event.version) }); +} +function parseJsonl(bytes) { + if (bytes.length > MAX_OUTPUT_BYTES) fail("JSONL output exceeds limit", "ELOOP_CODEX_OUTPUT_LIMIT"); + let source; try { source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { fail("JSONL output is not UTF-8", "ELOOP_CODEX_OUTPUT"); } + const lines = source.split("\n"); + if (lines.at(-1) !== "") fail("JSONL output is not LF terminated", "ELOOP_CODEX_OUTPUT"); + const events = []; + for (const line of lines.slice(0, -1)) { + if (!line || Buffer.byteLength(line) > MAX_JSONL_LINE_BYTES) fail("malformed JSONL output", "ELOOP_CODEX_OUTPUT"); + let value; try { value = JSON.parse(line); } catch { fail("malformed JSONL output", "ELOOP_CODEX_OUTPUT"); } + if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.type !== "string" || !value.type || Buffer.byteLength(value.type) > 128 || /[\0\r\n]/u.test(value.type)) fail("malformed JSONL event", "ELOOP_CODEX_OUTPUT"); + events.push(Object.freeze(value)); + } + return Object.freeze(events); +} +function isolation(value) { + // A direct child close is observable. It is not evidence about descendants, + // so the default controller must never manufacture an empty-process proof. + if (value === undefined) return { guarantees: DIRECT_GUARANTEES, terminate: (child, signal) => child.kill(signal), proveEmpty: async () => false, requireEmptyProof: false }; + const labels = ["freshSession", "filesystemWriteDeny", "foregroundHandle", "cancellation", "lifecycle"]; + if (!value || typeof value !== "object" || !value.guarantees || typeof value.terminate !== "function" + || typeof value.proveEmpty !== "function" || labels.some((key) => !["enforced", "detected-at-boundaries", "supervised", "unsupported"].includes(value.guarantees[key]))) fail("invalid foreground controller"); + return { ...value, requireEmptyProof: value.requireEmptyProof !== false }; +} + +/** A direct foreground process is fresh per call; reviewer write denial remains supervised. */ +export function startCodexInvocation({ profile, cwd, prompt, spawn = nodeSpawn, trustedIsolation }) { + const current = validateAgentProfile(profile); const launch = invocation(current, cwd, prompt); const controller = isolation(trustedIsolation); + let child; + try { + child = spawn(launch.command, launch.args, { cwd, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + } catch (error) { controller.dispose?.(); throw error; } + if (!child || !Number.isInteger(child.pid) || child.pid <= 0 || !child.stdout || !child.stderr || typeof child.once !== "function") { + child?.once?.("error", () => {}); // consume the deferred spawn error after failing closed synchronously + controller.dispose?.(); + fail("spawn did not return a foreground process handle", "ELOOP_CODEX_HANDLE"); + } + const stdout = []; let outputBytes = 0; let outputExceeded = false; let cancellationRequested = false; let termSent = false; let killSent = false; let settled = false; let finalizing = false; let forceTimer; + const boundedTerminate = () => { + if (!termSent) { try { termSent = controller.terminate(child, "SIGTERM") === true; } catch { termSent = false; } } + if (!forceTimer) forceTimer = setTimeout(() => { if (settled) return; try { killSent = controller.terminate(child, "SIGKILL") === true; } catch { killSent = false; } }, 100); + }; + const capture = (target) => (chunk) => { + const bytes = Buffer.from(chunk); outputBytes += bytes.length; + if (outputBytes > MAX_OUTPUT_BYTES) { outputExceeded = true; boundedTerminate(); return; } + if (target) target.push(bytes); + }; + child.stdout.on("data", capture(stdout)); child.stderr.on("data", capture(null)); + const completion = new Promise((resolve, reject) => { + const finalize = async ({ processError = null, exitCode = null, signal = null }) => { + if (settled || finalizing) return; finalizing = true; clearTimeout(forceTimer); + let empty = false; try { empty = await controller.proveEmpty({ pid: child.pid, exitCode, signal, processError }) === true; } catch { empty = false; } + finally { try { controller.dispose?.(); } catch { empty = false; } } + if (!empty && controller.requireEmptyProof && !cancellationRequested) { + settled = true; + resolve(Object.freeze({ + requested: launch.requested, providerReported: null, technicallyProven: Object.freeze({ argv: [launch.command, ...launch.args], pidObserved: true }), + guarantees: Object.freeze({ ...controller.guarantees }), events: Object.freeze([]), usage: null, usageStatus: "unavailable", + exitCode: Number.isInteger(exitCode) ? exitCode : null, signal: signal ?? null, cancellationRequested, + termination: Object.freeze({ termSent, killSent, emptyProven: false, descendants: controller.guarantees.lifecycle }), + quarantineRequired: true, outcome: "quarantined", + })); return; + } + try { + if (processError) throw processError; + if (outputExceeded) fail("JSONL output exceeds limit", "ELOOP_CODEX_OUTPUT_LIMIT"); + const events = parseJsonl(Buffer.concat(stdout)); const provider = providerReported(events); const usage = usageFrom(events); + // A foreground child close is sufficient to report a requested direct + // cancellation. Descendant containment remains explicitly unknown. + const cancellationClosed = cancellationRequested && (Number.isInteger(exitCode) || signal !== null); + settled = true; + resolve(Object.freeze({ + requested: launch.requested, providerReported: provider, technicallyProven: Object.freeze({ argv: [launch.command, ...launch.args], pidObserved: true }), + guarantees: Object.freeze({ ...controller.guarantees }), events, usage, usageStatus: usage ? "reported" : "unavailable", + exitCode: Number.isInteger(exitCode) ? exitCode : null, signal: signal ?? null, cancellationRequested, + termination: Object.freeze({ termSent, killSent, emptyProven: empty, descendants: controller.guarantees.lifecycle }), + quarantineRequired: cancellationRequested && !cancellationClosed, + outcome: cancellationRequested ? cancellationClosed ? "cancelled" : "quarantined" : exitCode === 0 && !signal ? "completed" : "failed", + })); + } catch (error) { settled = true; reject(error); } + }; + child.once("error", (error) => { void finalize({ processError: error }); }); + child.once("close", (exitCode, signal) => { void finalize({ exitCode, signal }); }); + }); + return Object.freeze({ + pid: child.pid, requested: launch.requested, completion, + cancel() { + if (settled || cancellationRequested) return false; cancellationRequested = true; + boundedTerminate(); + return termSent; + }, + }); +} + +/** Child JSONL may report identity/usage, but only the trusted host controller supplies guarantees. */ +export async function probeCodexCli({ profile, cwd, spawn = nodeSpawn, trustedIsolation }) { + const handle = startCodexInvocation({ profile, cwd, spawn, trustedIsolation, prompt: "Burnlist adapter probe. Report provider identity only." }); + const result = await handle.completion; + if (result.outcome !== "completed" || !result.providerReported) fail(`probe did not provide provider identity (${result.outcome})`, "ELOOP_CODEX_PROBE"); + return validateCodexProbe({ + schema: "burnlist-codex-probe@1", requested: result.requested, providerReported: result.providerReported, + technicallyProven: result.technicallyProven, guarantees: { ...result.guarantees, usage: result.usageStatus }, + }); +} diff --git a/src/loops/adapters/codex-cli.test.mjs b/src/loops/adapters/codex-cli.test.mjs new file mode 100644 index 00000000..dfd3bc64 --- /dev/null +++ b/src/loops/adapters/codex-cli.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { probeCodexCli, startCodexInvocation } from "./codex-cli.mjs"; +import { resolveStageOneRoutes } from "../agents/profile.mjs"; + +const fakeSource = `#!/usr/bin/env node +const args = process.argv.slice(2); +const mode = process.env.FAKE_MODE || "ok"; +if (mode === "hang") { process.on("SIGTERM",()=>{}); setInterval(() => {}, 1000); } +if (mode === "nonzero") { process.exit(7); } +if (mode === "bad") { process.stdout.write("not-json\\n"); process.exit(0); } +if (mode === "invalid-utf8") { process.stdout.write(Buffer.from([255,10])); process.exit(0); } +if (mode === "oversize") { process.stdout.write(Buffer.alloc(1048577,97)); process.exit(0); } +const session = mode === "same-session" ? "same" : "fresh-" + process.pid; +process.stdout.write(JSON.stringify({type:"thread.started",thread_id:session,model:args[args.indexOf("-m") + 1],version:"fake-1"})+"\\n"); +process.stdout.write(JSON.stringify({type:"burnlist.capability",guarantees:{freshSession:"enforced",filesystemWriteDeny:"enforced",foregroundHandle:"enforced",cancellation:"enforced",lifecycle:"enforced"}})+"\\n"); +const usage = mode === "overflow" ? {input_tokens:Number.MAX_SAFE_INTEGER,output_tokens:1} : mode === "missing-usage" ? null : {input_tokens:11,output_tokens:7,cached_input_tokens:3}; +process.stdout.write(JSON.stringify(usage ? {type:"turn.completed",usage} : {type:"turn.completed"})+"\\n"); +`; + +function fixture() { + const directory = mkdtempSync(join(tmpdir(), "burnlist-codex-adapter-")); const binary = join(directory, "fake-codex.mjs"); + writeFileSync(binary, fakeSource, { mode: 0o700 }); chmodSync(binary, 0o700); + return { directory, binary, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; +} +function profile(binary, id, authority, model = "gpt-5.6-terra", effort = "high") { return { schema: "burnlist-loop-agent-profile@1", id, adapter: "builtin:codex-cli", binary, model, effort, authority }; } +function unprovenIsolation() { + return { guarantees: { freshSession: "unsupported", filesystemWriteDeny: "unsupported", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }, terminate: (child, signal) => signal === "SIGTERM" ? true : child.kill(signal), proveEmpty: async () => false }; +} +function weakButEmptyIsolation() { return { ...unprovenIsolation(), proveEmpty: async () => true }; } +async function withMode(mode, fn) { + const before = process.env.FAKE_MODE; + try { process.env.FAKE_MODE = mode; return await fn(); } + finally { if (before === undefined) delete process.env.FAKE_MODE; else process.env.FAKE_MODE = before; } +} + +test("adapter uses exact ephemeral argv and ignores child capability claims", async () => { + const context = fixture(); + try { + const maker = profile(context.binary, "maker", "write"); + const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Implement." }).completion; + assert.deepEqual(result.technicallyProven.argv.slice(1, -1), ["exec", "--json", "--ephemeral", "-m", "gpt-5.6-terra", "-c", "model_reasoning_effort=high", "-s", "workspace-write", "-C", context.directory, "--skip-git-repo-check", "--"]); + assert.equal(result.technicallyProven.argv.at(-1), "Implement."); assert.equal(result.technicallyProven.pidObserved, true); + assert.deepEqual(result.usage, { inputTokens: 11, outputTokens: 7, cachedInputTokens: 3, totalTokens: 18 }); + + const direct = await startCodexInvocation({ profile: profile(context.binary, "reviewer", "read"), cwd: context.directory, prompt: "Direct." }).completion; + assert.equal(direct.outcome, "completed"); assert.equal(direct.termination.emptyProven, false); + } finally { context.cleanup(); } +}); + +test("cancellation uses bounded TERM/KILL and reports a closed direct child without descendant claims", async () => { + const context = fixture(); + try { + await withMode("hang", async () => { + const direct = startCodexInvocation({ profile: profile(context.binary, "maker", "write"), cwd: context.directory, prompt: "Wait.", trustedIsolation: unprovenIsolation() }); + assert.equal(direct.cancel(), true); assert.equal(direct.cancel(), false); const uncertain = await direct.completion; + assert.equal(uncertain.outcome, "cancelled"); assert.equal(uncertain.quarantineRequired, false); assert.equal(uncertain.termination.killSent, true); assert.equal(uncertain.termination.emptyProven, false); + + }); + } finally { context.cleanup(); } +}); + +test("JSONL is strict UTF-8 and usage overflow or absence is unavailable", async () => { + const context = fixture(); + try { + assert.throws(() => startCodexInvocation({ profile: profile(join(context.directory, "missing-codex"), "maker", "write"), cwd: context.directory, prompt: "Missing." }), (error) => error.code === "ELOOP_CODEX_HANDLE"); + const maker = profile(context.binary, "maker", "write"); + await withMode("nonzero", async () => { + const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Nonzero." }).completion; + assert.equal(result.outcome, "failed"); assert.equal(result.exitCode, 7); + }); + await withMode("bad", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); + await withMode("invalid-utf8", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad bytes." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); + await withMode("oversize", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Too much." }).completion, (error) => ["ELOOP_CODEX_OUTPUT_LIMIT", "ELOOP_CODEX_OUTPUT"].includes(error.code))); + for (const mode of ["missing-usage", "overflow"]) await withMode(mode, async () => { + const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Usage." }).completion; + assert.equal(result.usage, null); assert.equal(result.usageStatus, "unavailable"); + }); + } finally { context.cleanup(); } +}); + +test("each direct reviewer invocation has a fresh foreground PID and provider session", async () => { + const context = fixture(); + try { + const reviewer = profile(context.binary, "reviewer", "read"); + const first = startCodexInvocation({ profile: reviewer, cwd: context.directory, prompt: "Review one." }); + const second = startCodexInvocation({ profile: reviewer, cwd: context.directory, prompt: "Review two." }); + const [left, right] = await Promise.all([first.completion, second.completion]); + assert.notEqual(first.pid, second.pid); + assert.notEqual(left.providerReported.sessionId, right.providerReported.sessionId); + } finally { context.cleanup(); } +}); + +test("route binding requires host-enforced isolation and independent provider sessions", async () => { + const context = fixture(); + try { + const maker = profile(context.binary, "maker", "write"); const reviewer = profile(context.binary, "reviewer", "read", "gpt-5.6-sol", "medium"); + const weakMaker = await probeCodexCli({ profile: maker, cwd: context.directory, trustedIsolation: weakButEmptyIsolation() }); const weakReviewer = await probeCodexCli({ profile: reviewer, cwd: context.directory, trustedIsolation: weakButEmptyIsolation() }); + assert.throws(() => resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: weakMaker, reviewer: weakReviewer } }), (error) => error.code === "ELOOP_REVIEWER_ISOLATION"); + + const strongMaker = await probeCodexCli({ profile: maker, cwd: context.directory }); + const strongReviewer = await probeCodexCli({ profile: reviewer, cwd: context.directory }); + assert.equal(resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: strongMaker, reviewer: strongReviewer } }).review.guarantees.filesystemWriteDeny, "supervised"); + + await withMode("same-session", async () => { + const first = await probeCodexCli({ profile: maker, cwd: context.directory }); + const second = await probeCodexCli({ profile: reviewer, cwd: context.directory }); + assert.throws(() => resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: first, reviewer: second } }), (error) => error.code === "ELOOP_REVIEWER_ISOLATION"); + }); + } finally { context.cleanup(); } +}); + +test("direct foreground controllers are accepted without Docker authority", async () => { + const context = fixture(); + try { + const direct = await startCodexInvocation({ profile: profile(context.binary, "reviewer", "read"), + cwd: context.directory, prompt: "Fake.", trustedIsolation: { + guarantees: { freshSession: "enforced", filesystemWriteDeny: "enforced", foregroundHandle: "enforced", cancellation: "enforced", lifecycle: "enforced" }, + terminate: () => true, proveEmpty: async () => true, + } }).completion; + assert.equal(direct.outcome, "completed"); + } finally { context.cleanup(); } +}); diff --git a/src/loops/adapters/normalized-invocation.mjs b/src/loops/adapters/normalized-invocation.mjs new file mode 100644 index 00000000..f99f9fcf --- /dev/null +++ b/src/loops/adapters/normalized-invocation.mjs @@ -0,0 +1,147 @@ +import { startCodexInvocation } from "./codex-cli.mjs"; +import { runTrustedCapability } from "../capabilities/runner.mjs"; +import { parseBoundedObject } from "../contracts/contract.mjs"; + +const MAX_SUMMARY_BYTES = 1024; +const RESULT_TYPES = new Set(["complete", "approve", "reject", "escalate"]); +const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; +const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const RECIPE = /^er1-sha256:[a-f0-9]{64}$/u; +const POLICY = /^bp1-sha256:[a-f0-9]{64}$/u; +const fail = (message) => { throw Object.assign(new Error(`Loop invocation: ${message}`), { code: "ELOOP_INVOKE" }); }; +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); + +function clean(value) { + if (typeof value !== "string") return "process failed"; + const bytes = Buffer.from(value.replace(/[\0\r\n]/gu, " "), "utf8"); + let output = bytes.subarray(0, MAX_SUMMARY_BYTES).toString("utf8"); + // Buffer slicing can land in a multibyte code point; trim decoded code + // points until the returned UTF-8 representation is genuinely bounded. + while (Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) output = Array.from(output).slice(0, -1).join(""); + return output; +} +function route(routes, name) { + const value = routes?.[name]; + if (!value?.profile || typeof value.profile !== "object") fail(`missing ${name} route`); + return value.profile; +} +function binding(value) { + const keys = ["claimId", "assignmentId", "recipeRevision", "policyRevision", "inputCandidate", + "instructionBytes", "itemText", "candidateContext", "reviewerEvidence"]; + if (!exact(value, keys) || !CLAIM.test(value.claimId) || !ASSIGNMENT.test(value.assignmentId) + || !RECIPE.test(value.recipeRevision) || !POLICY.test(value.policyRevision) || !CANDIDATE.test(value.inputCandidate) + || typeof value.instructionBytes !== "string" || !value.instructionBytes || Buffer.byteLength(value.instructionBytes) > 65_536 + || typeof value.itemText !== "string" || !value.itemText || Buffer.byteLength(value.itemText) > 65_536 + || typeof value.candidateContext !== "string" || Buffer.byteLength(value.candidateContext) > 65_536 + || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 + || value.reviewerEvidence.some((item) => typeof item !== "string" || Buffer.byteLength(item) > 4096)) + fail("invalid invocation binding"); + return value; +} +function finalPrompt(invocation, node, current) { + return ["Burnlist Stage 1 invocation.", `run=${invocation.runId}`, `node=${node.id}`, `attempt=${invocation.attempt}`, + `claim=${current.claimId}`, `invocation=${invocation.invocationId}`, `assignment=${current.assignmentId}`, + `recipe=${current.recipeRevision}`, `policy=${current.policyRevision}`, `candidate=${current.inputCandidate}`, + `role=${node.role ?? "check"}`, "Your terminal response must be exactly one JSON object (no Markdown) with schema burnlist.agent-final@1, runId, nodeId, attempt, claimId, invocationId, assignmentId, recipeRevision, policyRevision, inputCandidate, outcome, summary.", + "FROZEN INSTRUCTIONS:", current.instructionBytes, "ASSIGNED ITEM:", current.itemText, + "CANDIDATE CONTEXT:", current.candidateContext, "REVIEW EVIDENCE:", JSON.stringify(current.reviewerEvidence)].join("\n"); +} +function finalTexts(events) { + const texts = []; + for (const event of events) { + if (event?.type !== "item.completed" || !event.item || typeof event.item !== "object" || Array.isArray(event.item) + || event.item.type !== "agent_message" || typeof event.item.text !== "string") continue; + texts.push(event.item.text); + } + if (!texts.length) fail("agent emitted no final message"); + return texts; +} +function agentResult(events, invocation, node, current) { + const texts = finalTexts(events), envelopes = []; + for (let index = 0; index < texts.length; index += 1) { + try { + const value = parseBoundedObject(Buffer.from(texts[index], "utf8"), { maximumBytes: 65_536, maximumDepth: 2, label: "agent final" }); + if (value.schema === "burnlist.agent-final@1") envelopes.push({ index, value }); + } catch { /* preceding conversational messages are permitted */ } + } + if (envelopes.length !== 1 || envelopes[0].index !== texts.length - 1) fail("malformed or ambiguous terminal agent result"); + const result = envelopes[0].value; + const keys = ["schema", "runId", "nodeId", "attempt", "claimId", "invocationId", "assignmentId", + "recipeRevision", "policyRevision", "inputCandidate", "outcome", "summary"]; + if (!exact(result, keys) || result.schema !== "burnlist.agent-final@1" || result.runId !== invocation.runId + || result.nodeId !== node.id || result.attempt !== invocation.attempt || result.invocationId !== invocation.invocationId + || result.claimId !== current.claimId || result.assignmentId !== current.assignmentId + || result.recipeRevision !== current.recipeRevision || result.policyRevision !== current.policyRevision + || result.inputCandidate !== current.inputCandidate + || !RESULT_TYPES.has(result.outcome) || typeof result.summary !== "string") fail("malformed or stale agent result"); + const allowed = node.mode === "task" ? result.outcome === "complete" : ["approve", "reject", "escalate"].includes(result.outcome); + if (!allowed) fail("agent result is illegal for node"); + return { kind: result.outcome, summary: clean(result.summary) }; +} +function boundedCompletion(handle, milliseconds) { + if (!milliseconds) return handle.completion.then((value) => ({ value, timedOut: false })); + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(async () => { + try { handle.cancel(); } catch { /* a broken controller is still bounded */ } + const forced = await Promise.race([ + Promise.resolve(handle.completion).then((value) => ({ value }), () => ({ value: null })), + new Promise((done) => setTimeout(() => done(null), 500)), + ]); + resolve(forced ? { ...forced, timedOut: true } : { value: null, cleanupLost: true }); + }, milliseconds); + }); + // Attaching both handlers avoids a later rejected completion becoming an + // unhandled rejection after the bounded timeout result has been returned. + const settled = Promise.resolve(handle.completion).then((value) => ({ value, timedOut: false }), () => ({ value: null, timedOut: false, failed: true })); + return Promise.race([settled, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * M3 adapter seam for M2's normalized invoke callback. `bindingFor` provides + * the immutable assignment/candidate pair that the M2 invocation shape lacks; + * a final cannot be accepted without both values matching exactly. + */ +export function createNormalizedInvocation({ repoRoot, routes, nodes, bindingFor, startAgent = startCodexInvocation, runCheck = runTrustedCapability, agentTimeoutMs = 0 }) { + if (typeof repoRoot !== "string" || !repoRoot.startsWith("/") || !(nodes instanceof Map) || typeof bindingFor !== "function" + || typeof startAgent !== "function" || typeof runCheck !== "function" || !Number.isSafeInteger(agentTimeoutMs) + || agentTimeoutMs < 0 || agentTimeoutMs > 86_400_000) fail("invalid dispatcher input"); + let active = null; + async function invoke(invocation) { + const node = nodes.get(invocation?.nodeId); + if (!invocation || typeof invocation !== "object" || !node || typeof node !== "object") fail("invalid invocation"); + let current; + try { current = binding(bindingFor(invocation, node)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } + if (node.mode === "review" && (!current.candidateContext || !current.reviewerEvidence.length)) + return Object.freeze({ kind: "error", summary: "review context is incomplete", outputBytes: 0 }); + if (node.kind === "check") { + try { + const checked = await runCheck({ repoRoot, capabilityId: node.capability, inputCandidate: current.inputCandidate }); + if (checked?.result?.inputCandidate !== current.inputCandidate || !["pass", "fail"].includes(checked?.result?.outcome)) fail("stale trusted check result"); + const summary = checked.result.timedOut ? "repository check timed out" : checked.result.truncated ? "repository check output limit" : `repository check ${checked.result.outcome}`; + return Object.freeze({ kind: checked.result.timedOut ? "timeout" : checked.result.outcome, summary, outputBytes: Buffer.isBuffer(checked.evidence) ? checked.evidence.length : 0 }); + } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } + } + const profile = route(routes, node.mode === "review" ? "review" : "implementation"); + try { + const handle = startAgent({ profile, cwd: repoRoot, prompt: finalPrompt(invocation, node, current) }); + if (!handle || typeof handle.cancel !== "function" || !handle.completion || typeof handle.completion.then !== "function") fail("agent did not return a foreground handle"); + active = handle; + const completed = await boundedCompletion(handle, agentTimeoutMs); + if (completed.cleanupLost) return Object.freeze({ kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); + if (completed.timedOut) return Object.freeze({ kind: "timeout", summary: "agent deadline exceeded", outputBytes: 0 }); + if (completed.failed) return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); + if (completed.value.outcome === "cancelled") return Object.freeze({ kind: "cancelled", summary: "agent cancelled", outputBytes: 0 }); + if (completed.value.outcome === "quarantined") return Object.freeze({ kind: "lost", summary: "agent process cleanup unproven", outputBytes: 0 }); + if (completed.value.outcome !== "completed") return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); + const result = agentResult(completed.value.events, invocation, node, current); + return Object.freeze({ ...result, outputBytes: Buffer.byteLength(JSON.stringify(completed.value.events), "utf8") }); + } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } + finally { active = null; } + } + Object.defineProperty(invoke, "cancel", { value: () => active?.cancel?.() === true, enumerable: false }); + Object.defineProperty(invoke, "active", { get: () => active !== null, enumerable: false }); + return invoke; +} diff --git a/src/loops/adapters/normalized-invocation.test.mjs b/src/loops/adapters/normalized-invocation.test.mjs new file mode 100644 index 00000000..768425bd --- /dev/null +++ b/src/loops/adapters/normalized-invocation.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createNormalizedInvocation } from "./normalized-invocation.mjs"; + +const profile = { id: "fake" }; +const routes = { implementation: { profile }, review: { profile: { id: "review" } } }; +const call = Object.freeze({ runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); +const maker = Object.freeze({ id: "implement", kind: "agent", mode: "task", role: "maker", instructions: "make" }); +const reviewer = Object.freeze({ id: "review", kind: "agent", mode: "review", role: "reviewer", instructions: "review" }); +const check = Object.freeze({ id: "verify", kind: "check", capability: "repo-verify" }); +const current = Object.freeze({ + claimId: `cl1-sha256:${"1".repeat(64)}`, assignmentId: `as1-sha256:${"b".repeat(64)}`, + recipeRevision: `er1-sha256:${"2".repeat(64)}`, policyRevision: `bp1-sha256:${"3".repeat(64)}`, + inputCandidate: `cm1-sha256:${"a".repeat(64)}`, instructionBytes: "Do the frozen work.\n", + itemText: "- [ ] M3 | Connect processes\n", candidateContext: "candidate-v1\n", reviewerEvidence: ["verify:pass"], +}); +function final(invocation, node, outcome = "complete", extra = {}) { + return JSON.stringify({ schema: "burnlist.agent-final@1", runId: invocation.runId, nodeId: node.id, attempt: invocation.attempt, + claimId: current.claimId, invocationId: invocation.invocationId, assignmentId: current.assignmentId, + recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, + inputCandidate: current.inputCandidate, outcome, summary: "ok", ...extra }); +} +function event(...texts) { return texts.map((text) => ({ type: "item.completed", item: { type: "agent_message", text } })); } +function agent(events, outcome = "completed") { return () => ({ cancel() { return true; }, completion: Promise.resolve({ outcome, events }) }); } +function dispatcher({ startAgent, runCheck, timeout = 0, bindingFor = () => current } = {}) { + return createNormalizedInvocation({ repoRoot: "/repo", routes, nodes: new Map([[maker.id, maker], [reviewer.id, reviewer], [check.id, check]]), bindingFor, + startAgent: startAgent ?? agent(event(final(call, maker))), runCheck: runCheck ?? (async () => ({ result: { outcome: "pass", inputCandidate: current.inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("check") })), agentTimeoutMs: timeout }); +} + +test("maps actual Codex agent-message finals for maker and fresh reviewer", async () => { + const seen = []; + const invoke = dispatcher({ startAgent: ({ profile: selected, prompt }) => { + seen.push(selected.id); const node = selected.id === "review" ? reviewer : maker; const invocationId = /invocation=([a-f0-9]{32})/u.exec(prompt)[1]; + assert.match(prompt, /FROZEN INSTRUCTIONS:\nDo the frozen work\.\n/u); + assert.match(prompt, /ASSIGNED ITEM:\n- \[ \] M3 \| Connect processes\n/u); + return { cancel() { return true; }, completion: Promise.resolve({ outcome: "completed", + events: event("Working notes before the terminal envelope.", final({ ...call, nodeId: node.id, invocationId }, node, node === reviewer ? "reject" : "complete")) }) }; + } }); + assert.equal((await invoke(call)).kind, "complete"); + assert.equal((await invoke({ ...call, nodeId: "review", invocationId: "c".repeat(32) })).kind, "reject"); + assert.deepEqual(seen, ["fake", "review"]); +}); + +test("rejects malformed or stale claim, revision, candidate, assignment, and invocation finals", async () => { + for (const extra of [{ invocationId: "b".repeat(32) }, { claimId: `cl1-sha256:${"9".repeat(64)}` }, + { recipeRevision: `er1-sha256:${"8".repeat(64)}` }, { policyRevision: `bp1-sha256:${"7".repeat(64)}` }, + { inputCandidate: `cm1-sha256:${"c".repeat(64)}` }, { assignmentId: `as1-sha256:${"d".repeat(64)}` }]) { + const invoke = dispatcher({ startAgent: agent(event(final(call, maker, "complete", extra))) }); + assert.equal((await invoke(call)).kind, "error"); + } + assert.equal((await dispatcher({ startAgent: agent(event("not json")) })(call)).kind, "error"); + assert.equal((await dispatcher({ startAgent: agent(event(final(call, maker), final(call, maker))) })(call)).kind, "error"); + assert.equal((await dispatcher({ bindingFor: () => ({ inputCandidate: current.inputCandidate }) })(call)).kind, "error"); +}); + +test("maps trusted checks including timeout and stale candidate without invoking an agent", async () => { + const verify = { ...call, nodeId: "verify" }; + assert.equal((await dispatcher()(verify)).kind, "pass"); + const timeout = await dispatcher({ runCheck: async () => ({ result: { outcome: "fail", inputCandidate: current.inputCandidate, timedOut: true, truncated: false }, evidence: Buffer.from("x") }) })(verify); + assert.equal(timeout.kind, "timeout"); + const stale = await dispatcher({ runCheck: async () => ({ result: { outcome: "pass", inputCandidate: `cm1-sha256:${"f".repeat(64)}`, timedOut: false, truncated: false }, evidence: Buffer.alloc(0) }) })(verify); + assert.equal(stale.kind, "error"); +}); + +test("agent deadline becomes lost if cancellation never settles", async () => { + const invoke = dispatcher({ timeout: 10, startAgent: () => ({ cancel() { return true; }, completion: new Promise(() => {}) }) }); + assert.deepEqual(await invoke(call), { kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); +}); + +test("summaries are valid UTF-8 and limited to 1024 bytes", async () => { + const result = await dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "€".repeat(500) }))) })(call); + assert.ok(Buffer.byteLength(result.summary, "utf8") <= 1024); + assert.doesNotThrow(() => Buffer.from(result.summary, "utf8").toString("utf8")); +}); diff --git a/src/loops/agents/profile.mjs b/src/loops/agents/profile.mjs new file mode 100644 index 00000000..d5ebd1e6 --- /dev/null +++ b/src/loops/agents/profile.mjs @@ -0,0 +1,128 @@ +import { isAbsolute } from "node:path"; +import { prefixed } from "../dsl/hash.mjs"; + +export const AGENT_PROFILE_SCHEMA = "burnlist-loop-agent-profile@1"; +export const STAGE_ONE_ROUTES = Object.freeze(["implementation.standard", "review.strong"]); +export const CODEX_MODELS = Object.freeze(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark"]); +export const REASONING_EFFORTS = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max"]); + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const controls = /[\0\r\n]/u; +const guarantee = new Set(["enforced", "detected-at-boundaries", "supervised", "unsupported"]); +const requiredReviewerGuarantees = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised" }); + +function fail(message, code = "ELOOP_AGENT_PROFILE") { throw Object.assign(new Error(`Loop agent: ${message}`), { code }); } +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function text(value, label, maximum = 4096) { + if (typeof value !== "string" || !value || Buffer.byteLength(value) > maximum || controls.test(value)) fail(`invalid ${label}`); + return value; +} +function identity(value, label) { + if (!exact(value, ["adapter", "binary", "model", "effort", "sandbox"])) fail(`invalid ${label} identity`); + if (value.adapter !== "builtin:codex-cli" || !isAbsolute(text(value.binary, `${label} binary`)) || !CODEX_MODELS.includes(value.model) || !REASONING_EFFORTS.includes(value.effort) || !["workspace-write", "read-only"].includes(value.sandbox)) fail(`invalid ${label} identity`); + return { adapter: value.adapter, binary: value.binary, model: value.model, effort: value.effort, sandbox: value.sandbox }; +} +function guarantees(value) { + const keys = ["freshSession", "filesystemWriteDeny", "foregroundHandle", "cancellation", "lifecycle", "usage"]; + if (!exact(value, keys) || keys.some((key) => key === "usage" ? !["reported", "unavailable"].includes(value[key]) : !guarantee.has(value[key]))) fail("invalid probe guarantees"); + return Object.fromEntries(keys.map((key) => [key, value[key]])); +} +function validInvocation(requested, argv) { + return Array.isArray(argv) && argv.length === 15 + && argv[0] === requested.binary && argv[1] === "exec" && argv[2] === "--json" && argv[3] === "--ephemeral" + && argv[4] === "-m" && argv[5] === requested.model && argv[6] === "-c" && argv[7] === `model_reasoning_effort=${requested.effort}` + && argv[8] === "-s" && argv[9] === requested.sandbox && argv[10] === "-C" + && typeof argv[11] === "string" && isAbsolute(argv[11]) && !controls.test(argv[11]) + && argv[12] === "--skip-git-repo-check" && typeof argv[13] === "string" && argv[13] === "--" + && typeof argv[14] === "string" && argv[14].length > 0 && Buffer.byteLength(argv[14]) <= 262144 && !argv[14].includes("\0"); +} + +/** Closed local profile. It requests an identity and authority but proves neither. */ +export function validateAgentProfile(value) { + const keys = ["schema", "id", "adapter", "binary", "model", "effort", "authority"]; + if (!exact(value, keys) || value.schema !== AGENT_PROFILE_SCHEMA || !slug.test(value.id)) fail("invalid profile"); + if (value.adapter !== "builtin:codex-cli" || !isAbsolute(text(value.binary, "binary")) || !["read", "write"].includes(value.authority)) fail("invalid profile"); + if (!CODEX_MODELS.includes(value.model)) fail(`model must be one of: ${CODEX_MODELS.join(", ")}`); + if (!REASONING_EFFORTS.includes(value.effort)) fail(`effort must be one of: ${REASONING_EFFORTS.join(", ")}`); + return Object.freeze({ schema: value.schema, id: value.id, adapter: value.adapter, binary: value.binary, model: value.model, effort: value.effort, authority: value.authority }); +} + +/** Provider statements and host-observed invocation evidence remain separate. */ +export function validateCodexProbe(value) { + if (!exact(value, ["schema", "requested", "providerReported", "technicallyProven", "guarantees"]) || value.schema !== "burnlist-codex-probe@1") fail("invalid Codex probe"); + const requested = identity(value.requested, "requested"); + if (!exact(value.providerReported, ["model", "sessionId", "version"]) || typeof value.providerReported.sessionId !== "string" || !value.providerReported.sessionId || [value.providerReported.model, value.providerReported.version].some((item) => item !== null && typeof item !== "string") || [value.providerReported.sessionId, value.providerReported.model, value.providerReported.version].filter((item) => item !== null).some((item) => !item || Buffer.byteLength(item) > 512 || controls.test(item))) fail("invalid provider-reported identity"); + if (!exact(value.technicallyProven, ["argv", "pidObserved"]) || value.technicallyProven.pidObserved !== true || !validInvocation(requested, value.technicallyProven.argv)) fail("invalid technically-proven identity"); + return Object.freeze({ + schema: value.schema, + requested, + providerReported: { model: value.providerReported.model, sessionId: value.providerReported.sessionId, version: value.providerReported.version }, + technicallyProven: { argv: [...value.technicallyProven.argv], pidObserved: true }, + guarantees: guarantees(value.guarantees), + }); +} + +export function requestedCodexIdentity(profile) { + const current = validateAgentProfile(profile); + return Object.freeze({ adapter: current.adapter, binary: current.binary, model: current.model, effort: current.effort, sandbox: current.authority === "write" ? "workspace-write" : "read-only" }); +} +export function agentProfileRevision(profile) { + const current = validateAgentProfile(profile); + return prefixed("ap1-sha256:", "agent-profile-v1", [Buffer.from(`${JSON.stringify(current)}\n`)]); +} +function sameIdentity(left, right) { return left.adapter === right.adapter && left.binary === right.binary && left.model === right.model && left.effort === right.effort && left.sandbox === right.sandbox; } +function routeMap(value) { + if (!exact(value, STAGE_ONE_ROUTES)) fail("Stage 1 routes must be closed"); + for (const route of STAGE_ONE_ROUTES) if (!slug.test(value[route])) fail(`invalid ${route} route`); + return value; +} + +/** Resolve M1 configuration authority without launching or probing an agent. */ +export function resolveConfiguredStageOneRoutes({ profiles, routes }) { + if (!Array.isArray(profiles) || profiles.length < 2 || profiles.length > 32) fail("invalid profiles"); + const byId = new Map(); + for (const profile of profiles.map(validateAgentProfile)) { + if (byId.has(profile.id)) fail("duplicate profile id"); + byId.set(profile.id, profile); + } + const mapped = routeMap(routes); + if (mapped["implementation.standard"] === mapped["review.strong"]) fail("implementation and review require distinct profile ids", "ELOOP_REVIEWER_ISOLATION"); + const resolveRoute = (route, authority) => { + const profile = byId.get(mapped[route]); + if (!profile) fail(`route ${route} references an unknown profile`); + if (profile.authority !== authority) fail(`route ${route} requires ${authority} authority`); + return Object.freeze({ route, profile, authority: profile.authority }); + }; + const implementation = resolveRoute("implementation.standard", "write"); + const review = resolveRoute("review.strong", "read"); + return Object.freeze({ + implementation: Object.freeze({ ...implementation, guarantees: Object.freeze({}) }), + review: Object.freeze({ ...review, guarantees: Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised" }) }), + }); +} + +/** Bind only the two Stage 1 routes; hard reviewer guarantees never downgrade. */ +export function resolveStageOneRoutes({ profiles, routes, probes }) { + if (!Array.isArray(profiles) || profiles.length < 2 || profiles.length > 32) fail("invalid profiles"); + const byId = new Map(); + for (const profile of profiles.map(validateAgentProfile)) { + if (byId.has(profile.id)) fail("duplicate profile id"); + byId.set(profile.id, profile); + } + const mapped = routeMap(routes); + if (!probes || typeof probes !== "object" || Array.isArray(probes)) fail("invalid probe map"); + const resolveRoute = (route, authority) => { + const profile = byId.get(mapped[route]); + if (!profile) fail(`route ${route} references an unknown profile`); + if (profile.authority !== authority) fail(`route ${route} requires ${authority} authority`); + const probe = validateCodexProbe(probes[profile.id]); const requested = requestedCodexIdentity(profile); + if (!sameIdentity(probe.requested, requested) || probe.providerReported.model !== null && probe.providerReported.model !== requested.model) fail(`probe does not bind profile ${profile.id}`); + return Object.freeze({ route, profile, requested, providerReported: probe.providerReported, technicallyProven: probe.technicallyProven, guarantees: probe.guarantees }); + }; + const implementation = resolveRoute("implementation.standard", "write"); + const review = resolveRoute("review.strong", "read"); + if (implementation.profile.id === review.profile.id || implementation.providerReported.sessionId === review.providerReported.sessionId) fail("implementation and review require independent provider sessions", "ELOOP_REVIEWER_ISOLATION"); + for (const [name, expected] of Object.entries(requiredReviewerGuarantees)) + if (review.guarantees[name] !== expected) fail(`review route lacks ${expected} ${name}`, "ELOOP_REVIEWER_ISOLATION"); + return Object.freeze({ implementation, review }); +} diff --git a/src/loops/agents/profile.test.mjs b/src/loops/agents/profile.test.mjs new file mode 100644 index 00000000..4c9af72c --- /dev/null +++ b/src/loops/agents/profile.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { requestedCodexIdentity, resolveConfiguredStageOneRoutes, validateAgentProfile, validateCodexProbe } from "./profile.mjs"; + +const profile = { schema: "burnlist-loop-agent-profile@1", id: "reviewer", adapter: "builtin:codex-cli", binary: "/usr/local/bin/codex", model: "gpt-5.6-sol", effort: "medium", authority: "read" }; +const requested = requestedCodexIdentity(profile); +const argv = [profile.binary, "exec", "--json", "--ephemeral", "-m", profile.model, "-c", "model_reasoning_effort=medium", "-s", "read-only", "-C", "/tmp/repo", "--skip-git-repo-check", "--", "Review."]; + +test("agent profiles are closed and request a role-specific sandbox", () => { + assert.deepEqual(requested, { adapter: "builtin:codex-cli", binary: profile.binary, model: profile.model, effort: profile.effort, sandbox: "read-only" }); + assert.throws(() => validateAgentProfile({ ...profile, unknown: true }), /invalid profile/u); + assert.throws(() => validateAgentProfile({ ...profile, binary: "codex" }), /invalid profile/u); +}); + +test("probe contract rejects non-ephemeral or identity-mismatched launch proof", () => { + const value = { schema: "burnlist-codex-probe@1", requested, providerReported: { model: profile.model, sessionId: "provider-session", version: "1.2.3" }, technicallyProven: { argv, pidObserved: true }, guarantees: { freshSession: "enforced", filesystemWriteDeny: "enforced", foregroundHandle: "enforced", cancellation: "enforced", lifecycle: "enforced", usage: "unavailable" } }; + assert.deepEqual(validateCodexProbe(value).technicallyProven.argv, argv); + assert.equal(validateCodexProbe({ ...value, providerReported: { model: null, sessionId: "provider-session", version: null } }).providerReported.model, null); + assert.throws(() => validateCodexProbe({ ...value, technicallyProven: { ...value.technicallyProven, argv: argv.filter((item) => item !== "--ephemeral") } }), /technically-proven/u); + assert.throws(() => validateCodexProbe({ ...value, requested: { ...requested, model: "gpt-5.6-terra" } }), /technically-proven/u); +}); + +test("configured Stage 1 routes have exact names, distinct profiles, and honest M1 guarantees", () => { + const maker = { ...profile, id: "maker", authority: "write" }; + const resolved = resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "maker", "review.strong": "reviewer" } }); + assert.equal(resolved.implementation.profile.id, "maker"); + assert.equal(resolved.implementation.authority, "write"); + assert.equal(resolved.review.profile.id, "reviewer"); + assert.deepEqual(resolved.review.guarantees, { freshSession: "enforced", filesystemWriteDeny: "supervised" }); + assert.throws(() => resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "maker", "review.strong": "maker" } }), /distinct profile ids/u); + assert.throws(() => resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "reviewer", "review.strong": "maker" } }), /requires write authority/u); +}); diff --git a/src/loops/assignment/assignment.mjs b/src/loops/assignment/assignment.mjs new file mode 100644 index 00000000..e7fafb42 --- /dev/null +++ b/src/loops/assignment/assignment.mjs @@ -0,0 +1,104 @@ +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { compileLoopPackage } from "../dsl/compile.mjs"; +import { findBurnlistDir, withLock } from "../../cli/lifecycle-moves.mjs"; +import { assignmentStore } from "./store.mjs"; +import { buildAssignment, containsLoopMarker, itemDigest, locateItemSpan, validateAssignedItem } from "./item-metadata.mjs"; +import { parseItemRef, parseLoopRef } from "./selectors.mjs"; +import { repositoryHazardAuthority } from "./hazards.mjs"; + +function raw(bytes) { return createHash("sha256").update(bytes).digest("hex"); } +function fail(message) { throw new Error(`Loop assignment: ${message}`); } +function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +function atomicWrite(path, bytes) { + const temp = join(dirname(path), `.${basename(path)}.${randomBytes(8).toString("hex")}.tmp`); let fd; + try { fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o666); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temp, path); syncDirectory(dirname(path)); } + finally { if (fd !== undefined) closeSync(fd); rmSync(temp, { force: true }); } +} + +function packageDirectory(loop) { + if (loop.name !== "review") fail(`installed Loop ${loop.selector} was not found`); + return resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops/review"); +} +export async function resolveBuiltin(loop) { + const compiled = await compileLoopPackage(packageDirectory(loop)); + if (!compiled.ok) fail(`installed Loop ${loop.selector} does not compile`); + if (loop.executable && loop.executable !== compiled.revisions.executable) fail("LoopRef executable pin does not match current package"); + return compiled; +} + +function planPath(repoRoot, item) { + const found = findBurnlistDir(repoRoot, item.burnlistId); + if (found.lifecycle.folder !== "inprogress") fail(`burnlist ${item.burnlistId} is not inprogress`); + return { directory: found.dir, path: join(found.dir, "burnlist.md") }; +} +function assertExpected(expected, whole, span, itemRef) { + if (!expected?.wholeDigest || !expected?.itemDigest) fail("prepared CAS token is required"); + if (expected.itemDigest !== buildItemDigest(itemRef, span)) fail("item CAS failed"); + if (expected.wholeDigest !== raw(whole)) fail("whole-file CAS failed"); +} +function buildItemDigest(itemRef, span) { return itemDigest(itemRef, span); } +function assertHazards(authority, input) { + const hazards = authority(input); + if (!Array.isArray(hazards)) fail("hazard authority returned invalid result"); + if (hazards.length) fail(`unsafe while ${hazards.join(", ")}`); +} + +/** Snapshot before source resolution; the locked mutation compares both bytes. */ +export function prepareItemMutation({ repoRoot, itemRef }) { + const item = parseItemRef(itemRef), target = planPath(repoRoot, item); + const whole = readFileSync(target.path), located = locateItemSpan(whole, item.itemId); + return { item, target, wholeDigest: raw(whole), itemDigest: itemDigest(item.selector, located.span) }; +} + +/** CLI-owned assignment: artifact first, then exactly one locked plan rewrite. */ +export async function assignLoopItem({ repoRoot, itemRef, loopRef, prepared, store = assignmentStore(repoRoot) }) { + const item = parseItemRef(itemRef), loop = parseLoopRef(loopRef); + const token = prepared ?? prepareItemMutation({ repoRoot, itemRef: item.selector }); + if (token.item?.selector !== item.selector) fail("prepared token item does not match"); + const compiled = await resolveBuiltin(loop); + const target = planPath(repoRoot, item); const canonicalSelector = loop.selector; + return withLock(target.directory, () => { + const whole = readFileSync(target.path); const located = locateItemSpan(whole, item.itemId); + assertExpected(token, whole, located.span, item.selector); + if (containsLoopMarker(located)) fail("duplicate, malformed, or handwritten Loop metadata"); + const assignment = buildAssignment(item.selector, located, { + selector: canonicalSelector, executable: compiled.revisions.executable, packageRevision: compiled.revisions.package, + }); + const after = Buffer.concat([whole.subarray(0, located.startByte), assignment.assignedSpan, whole.subarray(located.endByte)]); + store.save({ assignmentId: assignment.assignmentId, itemRef: item.selector, selector: canonicalSelector, + executionRevision: compiled.revisions.executable, packageRevision: compiled.revisions.package, + sourceRevision: compiled.revisions.source, unassignedItemDigest: assignment.unassignedDigest, + assignedItemDigest: assignment.assignedDigest }, compiled); + atomicWrite(target.path, after); + return { assignmentId: assignment.assignmentId, selector: canonicalSelector, executionRevision: compiled.revisions.executable, + packageRevision: compiled.revisions.package, unassignedItemDigest: assignment.unassignedDigest, assignedItemDigest: assignment.assignedDigest }; + }); +} + +/** Remove only a verified canonical block after a caller proves there are no Run hazards. */ +export function unassignLoopItem({ repoRoot, itemRef, prepared, hazardAuthority = repositoryHazardAuthority(repoRoot), store = assignmentStore(repoRoot) }) { + const item = parseItemRef(itemRef), token = prepared ?? prepareItemMutation({ repoRoot, itemRef: item.selector }); const target = planPath(repoRoot, item); + if (token.item?.selector !== item.selector) fail("prepared token item does not match"); + return withLock(target.directory, () => { + const whole = readFileSync(target.path), located = locateItemSpan(whole, item.itemId); + assertExpected(token, whole, located.span, item.selector); + const assignment = validateAssignedItem(item.selector, located); + const artifact = store.load(assignment["Assignment-Id"]); + if (artifact.itemRef !== item.selector || artifact.assignedItemDigest !== assignment.assignedDigest || artifact.unassignedItemDigest !== assignment.unassignedDigest || artifact.packageRevision !== assignment["Package-Revision"]) fail("stale assignment artifact"); + assertHazards(hazardAuthority, { repoRoot, itemRef: item.selector, assignmentId: assignment["Assignment-Id"], action: "unassign" }); + const after = Buffer.concat([whole.subarray(0, located.startByte), assignment.unassignedSpan, whole.subarray(located.endByte)]); + atomicWrite(target.path, after); + return { assignmentId: assignment["Assignment-Id"], unassignedItemDigest: assignment.unassignedDigest }; + }); +} + +/** Shared legacy gate. Terminal history alone is deliberately not a hazard. */ +export function assertDirectBurnAllowed({ repoRoot, itemRef, markdown, hazardAuthority = repositoryHazardAuthority(repoRoot) }) { + const item = parseItemRef(itemRef); const located = locateItemSpan(markdown, item.itemId); + if (containsLoopMarker(located)) fail("direct burn is blocked by Loop metadata"); + assertHazards(hazardAuthority, { repoRoot, itemRef: item.selector, assignmentId: null, action: "burn" }); + return located; +} diff --git a/src/loops/assignment/assignment.test.mjs b/src/loops/assignment/assignment.test.mjs new file mode 100644 index 00000000..86fb51fc --- /dev/null +++ b/src/loops/assignment/assignment.test.mjs @@ -0,0 +1,299 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { burnItem } from "../../cli/lifecycle-moves.mjs"; +import { assignLoopItem, assertDirectBurnAllowed, prepareItemMutation, resolveBuiltin, unassignLoopItem } from "./assignment.mjs"; +import { repositoryHazardAuthority } from "./hazards.mjs"; +import { resolveLoopAuthority, selectNonterminalRun } from "./resolver.mjs"; +import { assignmentDigest, buildAssignment, itemDigest, locateItemSpan, validateAssignedItem } from "./item-metadata.mjs"; +import { parseItemRef, parseLoopRef, parseRunRef, selectorKind } from "./selectors.mjs"; +import { assignmentStore } from "./store.mjs"; +import { runStore } from "../run/run-store.mjs"; +import { testGraph } from "../run/m2-test-fixtures.mjs"; + +const project = resolve(new URL("../../..", import.meta.url).pathname); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "burnlist-assignment-")); + const repo = join(root, "repo"), dir = join(repo, "notes", "burnlists", "inprogress", "260710-001"); + mkdirSync(dir, { recursive: true }); + const plan = join(dir, "burnlist.md"); + writeFileSync(plan, "# Test\n\n## Active Checklist\n- [ ] BUG-07 | Fix exact bytes\n Action: keep this line\n\n## Completed\n"); + return { repo, plan, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} +function ref() { return "item:260710-001#BUG-07"; } + +test("selectors are closed, disjoint, and reject noncanonical ULID overflow", () => { + assert.deepEqual(parseLoopRef("loop:builtin:review@er1-sha256:" + "a".repeat(64)), { selector: "loop:builtin:review", name: "review", executable: "er1-sha256:" + "a".repeat(64) }); + assert.equal(parseItemRef(ref()).itemId, "BUG-07"); + assert.equal(parseRunRef("run:01arz3ndektsv4rrffq69g5fav").id.length, 26); + for (const value of ["review", "item:260710-001#BUG-07", "run:81arz3ndektsv4rrffq69g5fav", "run:01ARZ3NDEKTSV4RRFFQ69G5FAV"]) assert.throws(() => parseRunRef(value), TypeError); + assert.equal(selectorKind("loop:builtin:review"), "loop"); assert.equal(selectorKind(ref()), "item"); +}); + +test("assignment writes canonical bytes, stores artifact before one replacement, and unassign restores exact bytes", async () => { + const context = fixture(); + try { + const before = readFileSync(context.plan); const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const assigned = readFileSync(context.plan); + assert.match(assigned.toString(), new RegExp(` Loop:\\n Assignment-Id: ${result.assignmentId}\\n Selector: loop:builtin:review\\n Execution-Revision: ${result.executionRevision}\\n Package-Revision: ${result.packageRevision}\\n`, "u")); + const assignedSpan = locateItemSpan(assigned, "BUG-07"); const checked = validateAssignedItem(ref(), assignedSpan); + assert.equal(checked.assignedDigest, result.assignedItemDigest); + const artifactPath = join(context.repo, ".local", "burnlist", "loop", "v2", "assignments", result.assignmentId.slice(11)); + assert.equal(lstatSync(artifactPath).mode & 0o777, 0o700); + assert.equal(readFileSync(join(artifactPath, "recipe.frozen")).includes("burnlist-loop-frozen@1"), true); + assert.throws(() => burnItem(context.repo, "260710-001", "BUG-07"), /Loop metadata/u); + unassignLoopItem({ repoRoot: context.repo, itemRef: ref() }); + assert.deepEqual(readFileSync(context.plan), before); + assert.doesNotThrow(() => assertDirectBurnAllowed({ repoRoot: context.repo, itemRef: ref(), markdown: before })); + } finally { context.cleanup(); } +}); + +test("assignment pin is verified and duplicate, malformed, stale, and edited metadata fail closed", async () => { + const context = fixture(); + try { + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: `loop:builtin:review@er1-sha256:${"0".repeat(64)}` }), /pin does not match/u); + const first = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }), /metadata/u); + writeFileSync(context.plan, readFileSync(context.plan, "utf8").replace("Fix exact bytes", "Edited bytes")); + assert.throws(() => unassignLoopItem({ repoRoot: context.repo, itemRef: ref() }), /stale or handwritten assignment id/u); + assert.throws(() => assertDirectBurnAllowed({ repoRoot: context.repo, itemRef: ref(), markdown: readFileSync(context.plan) }), /Loop metadata/u); + assert.match(first.assignmentId, /^as1-sha256:/u); + } finally { context.cleanup(); } +}); + +test("assignment and unassign CAS plus hazard authority reject competing or active state", async () => { + const context = fixture(); + try { + const prepared = prepareItemMutation({ repoRoot: context.repo, itemRef: ref() }); + writeFileSync(context.plan, `${readFileSync(context.plan, "utf8")}\n`); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review", prepared }), /whole-file CAS/u); + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + assert.throws(() => unassignLoopItem({ repoRoot: context.repo, itemRef: ref(), hazardAuthority: () => ["nonterminal run", "retained lease"] }), /nonterminal run, retained lease/u); + assert.throws(() => assertDirectBurnAllowed({ repoRoot: context.repo, itemRef: ref(), markdown: readFileSync(context.plan), hazardAuthority: () => ["quarantine"] }), /Loop metadata/u); + assert.match(result.unassignedItemDigest, /^id1-sha256:/u); + } finally { context.cleanup(); } +}); + +test("legacy burn remains unchanged when unassigned but accepts an injectable Run hazard authority", () => { + const context = fixture(); + try { + assert.throws(() => burnItem(context.repo, "260710-001", "BUG-07", false, { hazardAuthority: () => ["unreconciled checkout"] }), /unreconciled checkout/u); + assert.equal(burnItem(context.repo, "260710-001", "BUG-07"), true); + } finally { context.cleanup(); } +}); + +test("framed assignment identities use the exact unassigned and assigned item bytes", () => { + const bytes = Buffer.from("- [ ] BUG-07 | T\n\n"); const located = locateItemSpan(bytes, "BUG-07"); + const built = buildAssignment(ref(), located, { selector: "loop:builtin:review", executable: `er1-sha256:${"1".repeat(64)}`, packageRevision: `lp1-sha256:${"2".repeat(64)}` }); + assert.equal(built.unassignedDigest, itemDigest(ref(), bytes)); + assert.equal(built.assignmentId, assignmentDigest(ref(), built.unassignedDigest, "loop:builtin:review", `er1-sha256:${"1".repeat(64)}`)); + assert.equal(built.assignedDigest, itemDigest(ref(), built.assignedSpan)); +}); + +test("locked CLI accepts only prefixed state-changing selectors", () => { + const context = fixture(); + try { + const bin = join(project, "bin", "burnlist.mjs"); + const run = (...args) => execFileSync(process.execPath, [bin, ...args], { cwd: context.repo, encoding: "utf8" }); + assert.match(run("loop", "assign", ref(), "loop:builtin:review"), /^as1-sha256:/u); + assert.throws(() => run("loop", "assign", "260710-001#BUG-07", "review"), /Invalid ItemRef/u); + assert.match(run("loop", "unassign", ref()), /^as1-sha256:/u); + } finally { context.cleanup(); } +}); + +test("repository hazard authority reads production Runs and permits only safe terminal histories", async () => { + const context = fixture(); + try { + const authority = repositoryHazardAuthority(context.repo); + const bin = join(project, "bin", "burnlist.mjs"); + assert.deepEqual(authority({ itemRef: ref() }), []); + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const store = runStore(context.repo), first = "run:01arz3ndektsv4rrffq69g5fav"; + store.createRun({ runId: first, itemRef: ref(), graph: testGraph }); + assert.deepEqual(authority({ itemRef: ref() }), [`nonterminal Run ${first}`]); + assert.throws(() => unassignLoopItem({ repoRoot: context.repo, itemRef: ref() }), /nonterminal Run/u); + const lease = store.acquireLease(first).lease; + assert.deepEqual(authority({ itemRef: ref() }), ["nonterminal Run run:01arz3ndektsv4rrffq69g5fav"]); + store.terminalize(first, lease, "cancelled", "test"); + assert.deepEqual(authority({ itemRef: ref() }), []); + assert.match(unassignLoopItem({ repoRoot: context.repo, itemRef: ref() }).assignmentId, /^as1/u); + assert.match(execFileSync(process.execPath, [bin, "burn", "260710-001", "BUG-07"], { cwd: context.repo, encoding: "utf8" }), /^$/u); + assert.match(result.assignmentId, /^as1/u); + const converged = fixture(); + try { + await assignLoopItem({ repoRoot: converged.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const convergedStore = runStore(converged.repo), runId = "run:01arz3ndektsv4rrffq69g5faw"; + convergedStore.createRun({ runId, itemRef: ref(), graph: testGraph }); + convergedStore.terminalize(runId, convergedStore.acquireLease(runId).lease, "converged", "test"); + assert.deepEqual(repositoryHazardAuthority(converged.repo)({ itemRef: ref() }), [`completion-pending Run ${runId}`]); + assert.throws(() => unassignLoopItem({ repoRoot: converged.repo, itemRef: ref() }), /completion-pending Run/u); + } finally { converged.cleanup(); } + } finally { context.cleanup(); } +}); + +test("artifact is immutable, contained, and resolver selects only its declared authority", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const artifact = join(context.repo, ".local", "burnlist", "loop", "v2", "assignments", result.assignmentId.slice(11)); + assert.equal(lstatSync(join(artifact, "manifest.json")).mode & 0o777, 0o600); + const item = await resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }); + assert.equal(item.authority, "ITEM-PINNED"); assert.equal(item.executableDrift, false); + assert.equal((await resolveLoopAuthority({ repoRoot: context.repo, selector: "review" })).authority, "UNPINNED"); + await assert.rejects(resolveLoopAuthority({ repoRoot: context.repo, selector: "run:01arz3ndektsv4rrffq69g5fav" }), /E_RUN_UNAVAILABLE/u); + rmSync(join(artifact, "recipe.frozen")); symlinkSync("/etc/hosts", join(artifact, "recipe.frozen")); + await assert.rejects(resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }), /ELOOP_PIN_BYTES_UNAVAILABLE/u); + } finally { context.cleanup(); } +}); + +test("assignment artifact authority rejects persistent ancestor and leaf symlinks", async () => { + const context = fixture(); + try { + const outside = join(dirname(context.repo), "outside"), loop = join(context.repo, ".local", "burnlist", "loop"); + mkdirSync(join(context.repo, ".local", "burnlist"), { recursive: true }); mkdirSync(outside); + symlinkSync(outside, loop); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }), /unsafe directory loop/u); + assert.deepEqual(readdirSync(outside), []); + rmSync(loop); const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const assignments = join(context.repo, ".local", "burnlist", "loop", "v2", "assignments"); + const artifact = join(assignments, result.assignmentId.slice(11)), saved = `${artifact}.saved`; + renameSync(artifact, saved); symlinkSync(saved, artifact); + await assert.rejects(resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }), /ELOOP_PIN_BYTES_UNAVAILABLE/u); + rmSync(artifact); renameSync(saved, artifact); + const outsideAssignments = join(outside, "assignments"); cpSync(assignments, outsideAssignments, { recursive: true }); + renameSync(assignments, `${assignments}.saved`); symlinkSync(outsideAssignments, assignments); + await assert.rejects(resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }), /ELOOP_PIN_BYTES_UNAVAILABLE/u); + } finally { context.cleanup(); } +}); + +test("load and inspect anchor reads before deterministic leaf replacement cuts", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const plain = assignmentStore(context.repo).load(result.assignmentId); + for (const method of ["load", "inspect"]) { + let fired = false; + const store = assignmentStore(context.repo, { onCut(cut, detail) { + if (cut !== "read" || fired) return; + fired = true; const moved = `${detail.target}.moved`; + renameSync(detail.target, moved); mkdirSync(detail.target, { mode: 0o700 }); + } }); + assert.throws(() => store[method](result.assignmentId), /required artifact bytes are missing|unsafe directory/u); + const artifact = store.pathFor(result.assignmentId); + rmSync(artifact, { recursive: true }); renameSync(`${artifact}.moved`, artifact); + } + assert.equal(plain.assignmentId, result.assignmentId); + } finally { context.cleanup(); } +}); + +test("actual save collision and publication cuts cannot escape the anchored assignment root", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const originalStore = assignmentStore(context.repo), original = originalStore.load(result.assignmentId); + const compiled = await resolveBuiltin(parseLoopRef("loop:builtin:review")); + const outside = join(dirname(context.repo), "cut-outside"), canary = join(outside, "canary"); + mkdirSync(outside); writeFileSync(canary, "untouched", { mode: 0o600 }); + let fired = false; + const collision = assignmentStore(context.repo, { onCut(cut, detail) { + if (cut !== "collision" || fired) return; + fired = true; const moved = `${detail.target}.moved`; renameSync(detail.target, moved); symlinkSync(outside, detail.target); + } }); + assert.throws(() => collision.save(original, compiled), /unsafe directory/u); + const artifact = collision.pathFor(result.assignmentId); + rmSync(artifact); renameSync(`${artifact}.moved`, artifact); + const collisionChild = assignmentStore(context.repo, { workerCut: "move-collision-between-files", workerOutside: outside }); + assert.throws(() => collisionChild.save(original, compiled), /ancestor authority/u); + const movedCollision = join(outside, `${result.assignmentId.slice(11)}.moved`); + renameSync(movedCollision, artifact); + fired = false; + const next = { ...original, assignmentId: `as1-sha256:${"a".repeat(64)}` }; + const publication = assignmentStore(context.repo, { onCut(cut, detail) { + if (cut !== "publication" || fired) return; + fired = true; const moved = `${detail.root}.moved`; renameSync(detail.root, moved); symlinkSync(outside, detail.root); + } }); + assert.throws(() => publication.save(next, compiled), /unsafe directory/u); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + const assignments = join(context.repo, ".local", "burnlist", "loop", "v2", "assignments"); + rmSync(assignments); renameSync(`${assignments}.moved`, assignments); + fired = false; + const abaRecord = { ...original, assignmentId: `as1-sha256:${"b".repeat(64)}` }; + const aba = assignmentStore(context.repo, { onCut(cut, detail) { + if (cut !== "publication" || fired) return; + fired = true; const moved = `${detail.root}.aba`; + renameSync(detail.root, moved); symlinkSync(outside, detail.root); rmSync(detail.root); renameSync(moved, detail.root); + } }); + assert.equal(aba.save(abaRecord, compiled), aba.pathFor(abaRecord.assignmentId)); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + const tempRecord = { ...original, assignmentId: `as1-sha256:${"c".repeat(64)}` }; + const tempSwap = assignmentStore(context.repo, { workerCut: "move-temp-before-recipe", workerOutside: outside }); + assert.throws(() => tempSwap.save(tempRecord, compiled), /temporary directory escaped|ancestor authority mutation/u); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + for (const [index, workerCut] of ["aba-before-rename", "aba-after-rename"].entries()) { + const cutRecord = { ...original, assignmentId: `as1-sha256:${String(index + 4).repeat(64)}` }; + const childCut = assignmentStore(context.repo, { workerCut, workerOutside: outside }); + assert.throws(() => childCut.save(cutRecord, compiled), /ancestor authority mutation|unexpected entry/u); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + } + for (const entry of readdirSync(assignments)) if (entry.startsWith(".")) rmSync(join(assignments, entry), { recursive: true }); + const noClobberRecord = { ...original, assignmentId: `as1-sha256:${"6".repeat(64)}` }; + const noClobber = assignmentStore(context.repo, { workerCut: "create-empty-target", workerOutside: outside }); + assert.throws(() => noClobber.save(noClobberRecord, compiled), /ancestor authority mutation|bounded private regular file|required artifact bytes/u); + const reservedTarget = noClobber.pathFor(noClobberRecord.assignmentId); + assert.equal(lstatSync(reservedTarget).isDirectory(), true); + assert.deepEqual(readdirSync(reservedTarget), []); + } finally { context.cleanup(); } +}); + +test("assignment reads reject non-private directories and files", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const store = assignmentStore(context.repo), artifact = store.pathFor(result.assignmentId); + chmodSync(artifact, 0o755); assert.throws(() => store.load(result.assignmentId), /unsafe directory/u); + chmodSync(artifact, 0o700); chmodSync(join(artifact, "manifest.json"), 0o644); + assert.throws(() => store.inspect(result.assignmentId), /bounded private regular file/u); + chmodSync(join(artifact, "manifest.json"), 0o600); + assert.throws(() => assignmentStore(context.repo, { workerCut: "grow:recipe.frozen" }).load(result.assignmentId), /changed while reading/u); + } finally { context.cleanup(); } +}); + +test("restrictive umask cannot poison published assignment modes", async () => { + const context = fixture(), previous = process.umask(0o777); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const artifact = assignmentStore(context.repo).pathFor(result.assignmentId); + assert.equal(lstatSync(artifact).mode & 0o777, 0o700); + assert.equal(lstatSync(join(artifact, "manifest.json")).mode & 0o777, 0o600); + assert.equal(lstatSync(join(artifact, "recipe.frozen")).mode & 0o777, 0o600); + } finally { process.umask(previous); context.cleanup(); } +}); + +test("child-side cut between manifest and recipe cannot continue after leaf escape", async () => { + const context = fixture(); + try { + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review" }); + const outside = join(dirname(context.repo), "read-cut"), canary = join(outside, "canary"); + mkdirSync(outside); writeFileSync(canary, "untouched", { mode: 0o600 }); + const store = assignmentStore(context.repo, { workerCut: "move-leaf-between-files", workerOutside: outside }); + assert.throws(() => store.load(result.assignmentId), /ancestor authority/u); + assert.equal(readFileSync(canary, "utf8"), "untouched"); + } finally { context.cleanup(); } +}); + +test("duplicate active ids, item-CAS edits, and Run ambiguity reject without fallback", async () => { + const context = fixture(); + try { + writeFileSync(context.plan, readFileSync(context.plan, "utf8").replace("\n\n## Completed", "\n- [ ] BUG-07 | Duplicate\n\n## Completed")); + assert.throws(() => prepareItemMutation({ repoRoot: context.repo, itemRef: ref() }), /duplicated/u); + writeFileSync(context.plan, "# T\n\n## Active Checklist\n- [ ] BUG-07 | One\n\n## Completed\n"); + const prepared = prepareItemMutation({ repoRoot: context.repo, itemRef: ref() }); + writeFileSync(context.plan, readFileSync(context.plan, "utf8").replace("One", "Two")); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:builtin:review", prepared }), /item CAS/u); + assert.throws(() => selectNonterminalRun(ref(), []), /E_RUN_AMBIGUOUS/u); + assert.throws(() => selectNonterminalRun(ref(), [{ itemRef: ref(), runId: "run:01arz3ndektsv4rrffq69g5fav", state: "running" }, { itemRef: ref(), runId: "run:01arz3ndektsv4rrffq69g5fav", state: "paused" }]), /E_RUN_AMBIGUOUS/u); + } finally { context.cleanup(); } +}); diff --git a/src/loops/assignment/hazards.mjs b/src/loops/assignment/hazards.mjs new file mode 100644 index 00000000..81d34f79 --- /dev/null +++ b/src/loops/assignment/hazards.mjs @@ -0,0 +1,31 @@ +import { runStore } from "../run/run-store.mjs"; +import { parseItemRef } from "./selectors.mjs"; + +const SAFE_TERMINALS = new Set(["failed", "stopped", "budget-exhausted", "needs-human"]); + +export class LoopHazardError extends Error { + constructor(code, message) { super(`${code}: ${message}`); this.code = code; } +} +function bad(code, message) { throw new LoopHazardError(code, message); } + +/** + * Production Run journals are the only direct-burn/unassign authority. A + * converged Run deliberately remains hazardous until CLI completion consumes + * it; terminal failure, stop, exhaustion, and needs-human histories are safe. + */ +export function repositoryHazardAuthority(repoRoot) { + return ({ itemRef }) => { + const item = parseItemRef(itemRef); + const store = runStore(repoRoot); let runs, current; + try { runs = store.list(); current = store.readCurrentRun(item.selector); } + catch (error) { bad("E_LOOP_HAZARD_CORRUPT", error?.message ?? "production Run state is unreadable"); } + if (!Array.isArray(runs)) bad("E_LOOP_HAZARD_CORRUPT", "production Run state is invalid"); + const matching = runs.filter((run) => run?.itemRef === item.selector); + if (current && !matching.some((run) => run.runId === current.runId)) return [`unpublished current Run ${current.runId}`]; + if (matching.some((run) => typeof run.runId !== "string" || typeof run.state !== "string")) bad("E_LOOP_HAZARD_CORRUPT", "production Run projection is invalid"); + return matching.filter((run) => !SAFE_TERMINALS.has(run.state)).map((run) => { + if (run.state === "converged") return `completion-pending Run ${run.runId}`; + return `nonterminal Run ${run.runId}`; + }); + }; +} diff --git a/src/loops/assignment/item-metadata.mjs b/src/loops/assignment/item-metadata.mjs new file mode 100644 index 00000000..e4a84edf --- /dev/null +++ b/src/loops/assignment/item-metadata.mjs @@ -0,0 +1,98 @@ +import { prefixed } from "../dsl/hash.mjs"; + +const AS = /^as1-sha256:[a-f0-9]{64}$/u; +const ER = /^er1-sha256:[a-f0-9]{64}$/u; +const LP = /^lp1-sha256:[a-f0-9]{64}$/u; +const ITEM_START = /^- \[ \] ([A-Za-z0-9][A-Za-z0-9._-]{0,63}) \|/u; +const BOUNDARY = /^(?:- \[[ xX]\] |## Completed$)/u; +const FIELDS = ["Assignment-Id", "Selector", "Execution-Revision", "Package-Revision"]; + +function fail(message) { throw new Error(`Loop assignment: ${message}`); } +function digest(prefix, domain, fields) { return prefixed(prefix, domain, fields); } +function linesWithOffsets(bytes) { + const lines = []; let start = 0; + for (let index = 0; index < bytes.length; index += 1) if (bytes[index] === 10) { + lines.push({ start, end: index + 1, text: bytes.subarray(start, index).toString("utf8") }); start = index + 1; + } + if (start !== bytes.length) fail("burnlist and item spans must end with LF"); + return lines; +} + +export function itemDigest(itemRef, span) { + return digest("id1-sha256:", "item-v1", [Buffer.from(itemRef), Buffer.from(span)]); +} +export function assignmentDigest(itemRef, unassigned, selector, executable) { + return digest("as1-sha256:", "assignment-v1", [Buffer.from(itemRef), Buffer.from(unassigned), Buffer.from(selector), Buffer.from(executable)]); +} + +/** Return exact byte spans; blank and legacy content remains within its item. */ +export function locateItemSpan(markdown, itemId) { + const bytes = Buffer.isBuffer(markdown) ? Buffer.from(markdown) : Buffer.from(String(markdown), "utf8"); + const lines = linesWithOffsets(bytes); let start = -1; let matches = 0; + for (let index = 0; index < lines.length; index += 1) { + const match = ITEM_START.exec(lines[index].text); + if (match?.[1] === itemId) { start = index; matches += 1; } + } + if (start < 0) fail(`active item ${itemId} was not found`); + if (matches !== 1) fail(`active item ${itemId} is duplicated`); + let end = lines.length; + for (let index = start + 1; index < lines.length; index += 1) if (BOUNDARY.test(lines[index].text)) { end = index; break; } + const startByte = lines[start].start, endByte = end < lines.length ? lines[end].start : bytes.length; + return { bytes, lines, startLine: start, endLine: end, startByte, endByte, span: bytes.subarray(startByte, endByte) }; +} + +function metadataAt(lines, index) { + if (lines[index]?.text !== " Loop:") return null; + if (index + 4 >= lines.length) fail("truncated Loop metadata"); + const values = {}; + for (let offset = 0; offset < FIELDS.length; offset += 1) { + const match = /^ ([A-Za-z-]+): (.*)$/u.exec(lines[index + offset + 1].text); + if (!match || match[1] !== FIELDS[offset]) fail("noncanonical Loop metadata"); + values[match[1]] = match[2]; + } + return { line: index, endLine: index + 5, values }; +} + +export function findLoopMetadata(spanResult) { + const { lines, startLine, endLine } = spanResult; const markers = []; + for (let index = startLine; index < endLine; index += 1) if (/^\s*Loop:/u.test(lines[index].text)) markers.push(index); + if (!markers.length) return null; + if (markers.length !== 1 || lines[markers[0]].text !== " Loop:") fail("duplicate or handwritten Loop metadata"); + const meta = metadataAt(lines, markers[0]); + if (meta.endLine > endLine) fail("Loop metadata escapes item span"); + if (!AS.test(meta.values["Assignment-Id"]) || !ER.test(meta.values["Execution-Revision"]) || !LP.test(meta.values["Package-Revision"])) fail("noncanonical Loop digest"); + if (!/^loop:builtin:[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(meta.values.Selector)) fail("noncanonical Loop selector"); + return meta; +} + +function trimBlankSuffix(lines, start, end) { + let index = end; while (index > start && lines[index - 1].text === "") index -= 1; return index; +} +function block(values) { return Buffer.from([" Loop:", ...FIELDS.map((field) => ` ${field}: ${values[field]}`)].join("\n") + "\n"); } + +export function buildAssignment(itemRef, spanResult, { selector, executable, packageRevision }) { + if (findLoopMetadata(spanResult)) fail("item is already assigned"); + const unassignedDigest = itemDigest(itemRef, spanResult.span); + const assignmentId = assignmentDigest(itemRef, unassignedDigest, selector, executable); + const values = { "Assignment-Id": assignmentId, Selector: selector, "Execution-Revision": executable, "Package-Revision": packageRevision }; + const insertLine = trimBlankSuffix(spanResult.lines, spanResult.startLine, spanResult.endLine); + const insertAt = insertLine < spanResult.lines.length ? spanResult.lines[insertLine].start : spanResult.endByte; + const assignedSpan = Buffer.concat([spanResult.span.subarray(0, insertAt - spanResult.startByte), block(values), spanResult.span.subarray(insertAt - spanResult.startByte)]); + return { values, assignmentId, unassignedDigest, assignedDigest: itemDigest(itemRef, assignedSpan), assignedSpan, insertAt }; +} + +export function validateAssignedItem(itemRef, spanResult) { + const meta = findLoopMetadata(spanResult); if (!meta) fail("item has no Loop assignment"); + const metaStart = spanResult.lines[meta.line].start - spanResult.startByte; + const metaEnd = spanResult.lines[meta.endLine - 1].end - spanResult.startByte; + const unassigned = Buffer.concat([spanResult.span.subarray(0, metaStart), spanResult.span.subarray(metaEnd)]); + // The block must follow every nonblank item line and precede only the maximal + // trailing run of blank LF lines. This also rejects a block moved into prose. + if (spanResult.lines.slice(meta.endLine, spanResult.endLine).some((line) => line.text !== "")) fail("Loop metadata is not canonically placed"); + const unassignedDigest = itemDigest(itemRef, unassigned); + const expectedId = assignmentDigest(itemRef, unassignedDigest, meta.values.Selector, meta.values["Execution-Revision"]); + if (expectedId !== meta.values["Assignment-Id"]) fail("stale or handwritten assignment id"); + return { ...meta.values, unassignedSpan: unassigned, unassignedDigest, assignedDigest: itemDigest(itemRef, spanResult.span), metaStart, metaEnd }; +} + +export function containsLoopMarker(spanResult) { return spanResult.lines.slice(spanResult.startLine, spanResult.endLine).some((line) => /^\s*Loop:/u.test(line.text)); } diff --git a/src/loops/assignment/resolver.mjs b/src/loops/assignment/resolver.mjs new file mode 100644 index 00000000..a70b34b0 --- /dev/null +++ b/src/loops/assignment/resolver.mjs @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { locateItemSpan, validateAssignedItem } from "./item-metadata.mjs"; +import { resolveBuiltin } from "./assignment.mjs"; +import { parseItemRef, parseLoopRef, parseRunRef, selectorKind } from "./selectors.mjs"; +import { assignmentStore } from "./store.mjs"; + +export class LoopResolutionError extends Error { constructor(code, message) { super(`${code}: ${message}`); this.code = code; } } +function fail(code, message) { throw new LoopResolutionError(code, message); } +function assigned(repoRoot, item) { + let found; try { found = findBurnlistDir(repoRoot, item.burnlistId); } catch (error) { fail("E_ITEM_MISSING", error.message); } + let span; try { span = locateItemSpan(readFileSync(join(found.dir, "burnlist.md")), item.itemId); } catch (error) { fail("E_ITEM_MISSING", error.message); } + let meta; try { meta = validateAssignedItem(item.selector, span); } catch (error) { fail("E_ASSIGNMENT_INVALID", error.message); } + return { found, meta }; +} +function pinUnavailable(meta, current) { + const pinned = meta["Execution-Revision"], currentRevision = current?.revisions?.executable ?? "unavailable"; + fail("ELOOP_PIN_BYTES_UNAVAILABLE", `pinned=${pinned} current=${currentRevision}; restore the assignment artifact or safely unassign and reassign the item`); +} + +/** One selector-to-authority boundary. It never falls through between kinds. */ +export async function resolveLoopAuthority({ repoRoot, selector, runReader }) { + let kind; try { kind = selectorKind(selector, { allowViewSugar: true }); } + catch (error) { fail("E_LOOP_SELECTOR_INVALID", error.message); } + if (kind === "loop") { + const loop = parseLoopRef(selector, { allowViewSugar: true }); let compiled; + try { compiled = await resolveBuiltin(loop); } catch (error) { fail("E_LOOP_MISSING", error.message); } + return { authority: "UNPINNED", selector: loop.selector, compiled, executableRevision: compiled.revisions.executable }; + } + if (kind === "item") { + const item = parseItemRef(selector), value = assigned(repoRoot, item); + let current = null; try { current = await resolveBuiltin(parseLoopRef(value.meta.Selector)); } catch { /* pin remains authoritative */ } + let artifact; try { artifact = assignmentStore(repoRoot).load(value.meta["Assignment-Id"]); } catch { pinUnavailable(value.meta, current); } + if (artifact.itemRef !== item.selector || artifact.assignmentId !== value.meta["Assignment-Id"] || artifact.assignedItemDigest !== value.meta.assignedDigest || artifact.unassignedItemDigest !== value.meta.unassignedDigest || artifact.executionRevision !== value.meta["Execution-Revision"] || artifact.packageRevision !== value.meta["Package-Revision"]) pinUnavailable(value.meta, current); + // The assignment artifact remains the only executable authority. The fresh + // compilation is deliberately complete (rather than just a revision) so a + // view can show provenance drift without ever substituting its graph. + return { authority: "ITEM-PINNED", selector: item.selector, loopRef: artifact.selector, + assignment: value.meta, artifact, executableRevision: artifact.executionRevision, + currentCompiled: current, currentExecutableRevision: current?.revisions.executable ?? null, + executableDrift: current ? current.revisions.executable !== artifact.executionRevision : null }; + } + const run = parseRunRef(selector); + if (typeof runReader !== "function") fail("E_RUN_UNAVAILABLE", "Run-frozen authority is unavailable before the Run store is installed"); + let record; try { record = await runReader(run.selector); } catch (error) { fail("E_RUN_MISSING", error.message); } + if (!record || record.runId !== run.selector || !Buffer.isBuffer(record.frozenRecipe)) fail("E_RUN_CORRUPT", "Run reader did not return a verified frozen recipe"); + let frozen; try { frozen = loadFrozenRecipe(record.frozenRecipe); } catch (error) { fail("E_RUN_CORRUPT", error.message); } + return { authority: "RUN-FROZEN", selector: run.selector, loopRef: `loop:builtin:${frozen.ir.id}`, + run: record, frozen, executableRevision: frozen.revisions.executable }; +} + +export function selectNonterminalRun(itemRef, runs) { + const item = parseItemRef(itemRef); if (!Array.isArray(runs)) fail("E_RUN_CORRUPT", "Run list is unavailable"); + const active = runs.filter((run) => run?.itemRef === item.selector && ["prepared", "running", "pausing", "paused", "quarantined", "converged-pending-completion", "completion-needs-human"].includes(run.state)); + if (active.length !== 1) fail("E_RUN_AMBIGUOUS", `${active.length ? "multiple" : "no"} nonterminal Runs: ${active.slice(0, 8).map((run) => run.runId).join(",")}`); + parseRunRef(active[0].runId); return active[0]; +} diff --git a/src/loops/assignment/selectors.mjs b/src/loops/assignment/selectors.mjs new file mode 100644 index 00000000..a33b2502 --- /dev/null +++ b/src/loops/assignment/selectors.mjs @@ -0,0 +1,40 @@ +import { RUN_REF } from "../run/run-ref.mjs"; + +const HEX = "[a-f0-9]{64}"; +const LOOP = new RegExp(`^loop:builtin:([a-z0-9]+(?:-[a-z0-9]+)*)(?:@(er1-sha256:${HEX}))?$`, "u"); +const ITEM = /^item:([0-9]{6}-[0-9]{3})#([A-Za-z0-9][A-Za-z0-9._-]{0,63})$/u; + +function reject(label, value) { throw new TypeError(`Invalid ${label}: ${String(value)}`); } + +/** Parse only the closed Stage 1 Loop selector grammar. */ +export function parseLoopRef(value, { allowViewSugar = false } = {}) { + if (allowViewSugar && value === "review") return { selector: "loop:builtin:review", name: "review", executable: null }; + const match = typeof value === "string" ? LOOP.exec(value) : null; + if (!match) reject("LoopRef", value); + return { selector: `loop:builtin:${match[1]}`, name: match[1], executable: match[2] ?? null }; +} + +export function parseItemRef(value) { + const match = typeof value === "string" ? ITEM.exec(value) : null; + if (!match) reject("ItemRef", value); + return { selector: value, burnlistId: match[1], itemId: match[2] }; +} + +// Crockford's 26 digits encode 130 bits. Canonical ULIDs reserve the top two. +export function parseRunRef(value) { + if (typeof value !== "string" || !RUN_REF.test(value)) reject("RunRef", value); + return { selector: value, id: value.slice(4) }; +} + +export function selectorKind(value, options) { + try { return parseLoopRef(value, options).selector ? "loop" : null; } catch { /* closed alternatives */ } + try { parseItemRef(value); return "item"; } catch { /* closed alternatives */ } + try { parseRunRef(value); return "run"; } catch { /* closed alternatives */ } + reject("Loop selector", value); +} + +export const SELECTOR_GRAMMARS = Object.freeze({ + LoopRef: LOOP, + ItemRef: ITEM, + RunRef: RUN_REF, +}); diff --git a/src/loops/assignment/store-worker.mjs b/src/loops/assignment/store-worker.mjs new file mode 100644 index 00000000..38f5bd35 --- /dev/null +++ b/src/loops/assignment/store-worker.mjs @@ -0,0 +1,154 @@ +import { randomBytes } from "node:crypto"; +import { + appendFileSync, chmodSync, closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, + readFileSync, readSync, readdirSync, renameSync, writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +function fail(message) { throw new Error(`Assignment artifact: ${message}`); } +function identity(stat) { return `${stat.dev}:${stat.ino}`; } +const authority = []; +function marker(stat) { return `${stat.ctimeMs}:${stat.mtimeMs}`; } +function remember(path, stat) { authority.push({ path, identity: identity(stat), marker: marker(stat), mode: stat.mode & 0o777 }); } +function refreshParent() { + if (!authority.length) return; + const stat = lstatSync(".."), parent = authority.at(-1); + if (identity(stat) !== parent.identity) fail("ancestor authority changed"); + parent.marker = marker(stat); +} +function validateAuthority() { + for (let index = 0; index < authority.length; index += 1) { + const expected = authority[index], stat = lstatSync(expected.path); + if (stat.isSymbolicLink() || !stat.isDirectory() || identity(stat) !== expected.identity || (stat.mode & 0o777) !== expected.mode) fail("ancestor authority changed"); + if (marker(stat) !== expected.marker) fail(`ancestor authority mutation detected ${expected.path}`); + } +} +function refreshCurrent() { + const current = authority.at(-1), stat = lstatSync("."); + if (identity(stat) !== current.identity || (stat.mode & 0o777) !== current.mode) fail("current authority changed"); + current.marker = marker(stat); +} +function regular(path, limit) { + const before = lstatSync(path); + if (before.isSymbolicLink() || !before.isFile() || (before.mode & 0o777) !== 0o600 || before.size > limit) fail(`${path} is not a bounded private regular file`); + let fd; + try { + fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); + const opened = fstatSync(fd); + if (!opened.isFile() || identity(opened) !== identity(before) || opened.size !== before.size || (opened.mode & 0o777) !== 0o600) fail(`${path} changed while opening`); + if (testCut === `grow:${path}`) appendFileSync(path, "x"); + const bounded = Buffer.alloc(limit + 1); let offset = 0, count; + do { count = readSync(fd, bounded, offset, bounded.length - offset, null); offset += count; } while (count && offset < bounded.length); + const after = fstatSync(fd); + if (offset !== opened.size || offset > limit || after.size !== opened.size || identity(after) !== identity(opened) || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) fail(`${path} changed while reading`); + return bounded.subarray(0, offset); + } finally { if (fd !== undefined) closeSync(fd); } +} +function cwd(expected, mode) { + const stat = lstatSync("."); + if (!stat.isDirectory() || identity(stat) !== expected || (mode && (stat.mode & 0o777) !== mode)) fail("anchored directory authority changed"); +} +function enter(name, create, mode) { + validateAuthority(); + let before, created = false; + try { before = lstatSync(name); } catch (error) { + if (error?.code !== "ENOENT" || !create) throw error; + mkdirSync(name, { mode: 0o700 }); chmodSync(name, 0o700); before = lstatSync(name); created = true; + } + if (before.isSymbolicLink() || !before.isDirectory() || (mode && (before.mode & 0o777) !== mode)) fail(`unsafe directory ${name}`); + const path = join(authority.at(-1).path, name); + process.chdir(name); cwd(identity(before), mode); if (created) refreshParent(); remember(path, before); validateAuthority(); +} +function enterRoot(create) { + for (const part of [".local", "burnlist", "loop", "v2"]) enter(part, create); + enter("assignments", create, 0o700); +} +function durable(path, bytes) { + const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600); + try { fchmodSync(fd, 0o600); writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); } +} +function members(allowedTemp = null) { + const entries = readdirSync(".", { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === allowedTemp && entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (!/^[a-f0-9]{64}$/u.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) fail("assignment root contains an unexpected entry"); + } + return entries.map((entry) => entry.name).sort(); +} +function input() { return JSON.parse(readFileSync(0, "utf8")); } + +const [command, expected, testCut] = process.argv.slice(2); +const payload = input(); +cwd(expected); remember(process.cwd(), lstatSync(".")); +if (command === "load") { + const { name, outside } = payload; enterRoot(false); enter(name, false, 0o700); + validateAuthority(); + const manifest = regular("manifest.json", 16 * 1024); + if (testCut === "move-leaf-between-files") renameSync(`../${name}`, `${outside}/${name}.moved`); + validateAuthority(); + const recipe = regular("recipe.frozen", 1024 * 1024); + validateAuthority(); + process.stdout.write(JSON.stringify({ manifest: manifest.toString("base64"), recipe: recipe.toString("base64") })); +} else if (command === "save") { + const { name, manifest, recipe, outside, root } = payload; enterRoot(true); + validateAuthority(); + const rootIdentity = identity(lstatSync(".")), temp = `.${name}.${randomBytes(8).toString("hex")}.tmp`; + const expectedManifest = Buffer.from(manifest, "base64"), expectedRecipe = Buffer.from(recipe, "base64"); + const collision = () => { + enter(name, false, 0o700); + validateAuthority(); + const actualManifest = regular("manifest.json", 16 * 1024); + if (testCut === "move-collision-between-files") renameSync(`../${name}`, `${outside}/${name}.moved`); + validateAuthority(); + const actualRecipe = regular("recipe.frozen", 1024 * 1024); + validateAuthority(); + if (!actualManifest.equals(expectedManifest) || !actualRecipe.equals(expectedRecipe)) fail("assignment id collision"); + process.stdout.write(JSON.stringify({ status: "existing" })); + }; + let existing = true; + try { lstatSync(name); } catch (error) { if (error?.code === "ENOENT") existing = false; else throw error; } + if (existing) { collision(); process.exit(0); } + const beforeMembers = members(); + let tempStat; + { + enter(temp, true, 0o700); tempStat = lstatSync("."); + if (testCut === "move-temp-before-recipe") renameSync(`../${temp}`, `${outside}/${temp}.moved`); + validateAuthority(); if (identity(lstatSync("..")) !== rootIdentity) fail("temporary directory escaped assignment root"); + durable("recipe.frozen", expectedRecipe); + refreshCurrent(); validateAuthority(); if (identity(lstatSync("..")) !== rootIdentity) fail("temporary directory escaped assignment root"); + durable("manifest.json", expectedManifest); + refreshCurrent(); validateAuthority(); if (identity(lstatSync("..")) !== rootIdentity) fail("temporary directory escaped assignment root"); + const fd = openSync(".", constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } + validateAuthority(); process.chdir(".."); authority.pop(); cwd(rootIdentity, 0o700); validateAuthority(); + const withTemp = members(temp); + if (withTemp.length !== beforeMembers.length + 1 || !withTemp.includes(temp) || beforeMembers.some((entry) => !withTemp.includes(entry))) fail("assignment root membership changed"); + if (testCut === "aba-before-rename") { renameSync(root, `${outside}/assignments.aba`); renameSync(`${outside}/assignments.aba`, root); } + validateAuthority(); + if (testCut === "create-empty-target") mkdirSync(name, { mode: 0o700 }); + let reservation; + try { mkdirSync(name, { mode: 0o700 }); chmodSync(name, 0o700); reservation = lstatSync(name); } + catch (error) { + if (error?.code !== "EEXIST") throw error; + collision(); process.exit(0); + } + if (!reservation.isDirectory() || reservation.isSymbolicLink() || (reservation.mode & 0o777) !== 0o700 + || readdirSync(name).length !== 0) fail("target reservation changed"); + refreshCurrent(); validateAuthority(); + const reserved = lstatSync(name); + if (identity(reserved) !== identity(reservation) || readdirSync(name).length !== 0) fail("target reservation changed"); + renameSync(temp, name); + if (testCut === "aba-after-rename") { + renameSync(root, `${outside}/assignments.aba`); renameSync(`${outside}/assignments.aba`, root); validateAuthority(); + } + refreshCurrent(); validateAuthority(); + const afterMembers = members(); + if (afterMembers.length !== beforeMembers.length + 1 || !afterMembers.includes(name) || beforeMembers.some((entry) => !afterMembers.includes(entry))) fail("assignment root membership changed"); + const rootFd = openSync(".", constants.O_RDONLY); try { fsyncSync(rootFd); } finally { closeSync(rootFd); } + validateAuthority(); enter(name, false, 0o700); + if (identity(lstatSync(".")) !== identity(tempStat)) fail("published artifact is not the prepared temp"); + const publishedManifest = regular("manifest.json", 16 * 1024), publishedRecipe = regular("recipe.frozen", 1024 * 1024); + validateAuthority(); + if (!publishedManifest.equals(expectedManifest) || !publishedRecipe.equals(expectedRecipe)) fail("published artifact bytes changed"); + process.stdout.write(JSON.stringify({ status: "created" })); + } +} else fail("invalid worker operation"); diff --git a/src/loops/assignment/store.mjs b/src/loops/assignment/store.mjs new file mode 100644 index 00000000..9648aa9b --- /dev/null +++ b/src/loops/assignment/store.mjs @@ -0,0 +1,74 @@ +import { execFileSync } from "node:child_process"; +import { lstatSync, realpathSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { freezeRecipe, loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; + +const ID = /^as1-sha256:[a-f0-9]{64}$/u; +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const ITEM = /^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const MANIFEST_KEYS = ["schema", "assignmentId", "itemRef", "selector", "executionRevision", "packageRevision", "sourceRevision", "unassignedItemDigest", "assignedItemDigest", "frozenRecipeDigest", "frozenRecipeSize"]; + +function fail(message) { throw new Error(`Assignment artifact: ${message}`); } +function name(id) { if (!ID.test(id)) fail("invalid assignment id"); return id.slice(11); } +function exact(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key, index) => Object.keys(value)[index] === key); } +function manifest(record, recipe) { + const value = { schema: "burnlist-loop-assignment@1", assignmentId: record.assignmentId, itemRef: record.itemRef, selector: record.selector, + executionRevision: record.executionRevision, packageRevision: record.packageRevision, sourceRevision: record.sourceRevision, + unassignedItemDigest: record.unassignedItemDigest, assignedItemDigest: record.assignedItemDigest, + frozenRecipeDigest: rawSha256(recipe), frozenRecipeSize: recipe.length }; + return Buffer.from(`${JSON.stringify(value)}\n`); +} +function parseManifest(bytes, id) { + let value; try { value = JSON.parse(bytes.toString("utf8")); } catch { fail("manifest is not JSON"); } + if (!exact(value, MANIFEST_KEYS) || !Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail("manifest is not canonical"); + if (value.schema !== "burnlist-loop-assignment@1" || value.assignmentId !== id || !ITEM.test(value.itemRef) || !/^loop:builtin:[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.selector) || !/^er1-sha256:[a-f0-9]{64}$/u.test(value.executionRevision) || !/^lp1-sha256:[a-f0-9]{64}$/u.test(value.packageRevision) || !/^ls1-sha256:[a-f0-9]{64}$/u.test(value.sourceRevision) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.unassignedItemDigest) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.assignedItemDigest) || !DIGEST.test(value.frozenRecipeDigest) || !Number.isSafeInteger(value.frozenRecipeSize) || value.frozenRecipeSize < 1) fail("manifest has invalid bindings"); + return value; +} + +const worker = fileURLToPath(new URL("./store-worker.mjs", import.meta.url)); +function stat(path) { const value = lstatSync(path); if (value.isSymbolicLink() || !value.isDirectory()) fail(`unsafe directory ${path}`); return `${value.dev}:${value.ino}`; } +function invoke(command, cwd, expected, value, testCut) { + try { + return execFileSync(process.execPath, [worker, command, expected, testCut || ""], { + cwd, input: JSON.stringify(value || {}), encoding: "utf8", maxBuffer: 3 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + const detail = String(error?.stderr || ""); + if (/code: 'ENOENT'|ENOENT:/u.test(detail)) throw Object.assign(new Error("Assignment artifact: required artifact bytes are missing"), { code: "ENOENT" }); + const messages = detail.match(/Assignment artifact: [^\n]+/gu) || []; + throw new Error(messages.findLast((message) => !message.includes("${")) || "Assignment artifact: anchored worker failed"); + } +} +function within(repo, path) { const value = relative(repo, path); return value === "" || (value !== ".." && !value.startsWith(`..${sep}`)); } + +export function assignmentStore(repoRoot, options = {}) { + const repository = realpathSync(resolve(repoRoot)), root = resolve(repository, ".local", "burnlist", "loop", "v2", "assignments"); + if (!within(repository, root)) fail("artifact root escapes repository"); + const repositoryIdentity = stat(repository); + const cut = (name, detail = {}) => options.onCut?.(name, Object.freeze({ repository, root, ...detail })); + const pathFor = (id) => join(root, name(id)); + const read = (id) => { + const target = pathFor(id); cut("read", { target }); + const result = JSON.parse(invoke("load", repository, repositoryIdentity, { name: name(id), outside: options.workerOutside }, options.workerCut)); + const manifestBytes = Buffer.from(result.manifest, "base64"), recipe = Buffer.from(result.recipe, "base64"); + const value = parseManifest(manifestBytes, id); + if (recipe.length !== value.frozenRecipeSize || rawSha256(recipe) !== value.frozenRecipeDigest) fail("recipe bytes do not match manifest"); + const frozen = loadFrozenRecipe(recipe); + if (frozen.revisions.executable !== value.executionRevision || frozen.revisions.package !== value.packageRevision || frozen.revisions.source !== value.sourceRevision) fail("frozen recipe bindings do not match manifest"); + return { ...value, frozen, frozenRecipeBytes: Buffer.from(recipe), path: target }; + }; + return { + pathFor, + save(record, compiled) { + const recipe = freezeRecipe(compiled), bytes = manifest(record, recipe), target = pathFor(record.assignmentId); + cut("collision", { target }); cut("publication", { target }); + invoke("save", repository, repositoryIdentity, { name: name(record.assignmentId), manifest: bytes.toString("base64"), recipe: recipe.toString("base64"), outside: options.workerOutside, root }, options.workerCut); + return target; + }, + load: read, + inspect(id) { return read(id); }, + }; +} diff --git a/src/loops/capabilities/capabilities.test.mjs b/src/loops/capabilities/capabilities.test.mjs new file mode 100644 index 00000000..ad3c6b61 --- /dev/null +++ b/src/loops/capabilities/capabilities.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { chmodSync, fstatSync, mkdtempSync, mkdirSync, readFileSync, readSync, realpathSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindCapabilitySymbols, canonicalCapabilityBytes, capabilityRevision, parseCapabilityCatalog, readCapabilityCatalog, validateCapabilityGrants } from "./contract.mjs"; +import { preflightCapability, runTrustedCapability, validateCapabilityEvidence } from "./runner.mjs"; +import { readTrustedCapability, trustCapability } from "./trust.mjs"; +import { configRoot, localRecordPath } from "../config/store.mjs"; +import { holdSnapshot, releaseSnapshot, snapshotTarget } from "./snapshot.mjs"; + +function temp() { const root = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-capability-"))); mkdirSync(join(root, ".burnlist")); mkdirSync(join(root, "src")); writeFileSync(join(root, "src", "input"), "a"); return root; } +function policy(overrides = {}) { return { id: "repo-verify", argv: [process.execPath, "-e", "process.stdout.write('ok')"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000, ...overrides }; } +function grants(source, overrides = {}) { return { argv: source.argv, cwd: source.cwd, environment: source.environment, network: source.network, filesystem: source.filesystem, output: source.output, maxMilliseconds: source.maxMilliseconds, ...overrides }; } +function writeCatalog(root, capabilities = [policy()]) { writeFileSync(join(root, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities })}\n`); } +function trust(root) { const current = readCapabilityCatalog(root).capabilities[0]; return trustCapability({ repoRoot: root, capability: current, grants: grants(current) }); } + +test("closed canonical policy binds compiler check symbols and separate narrower grants", () => { + const root = temp(); writeCatalog(root); const current = readCapabilityCatalog(root).capabilities[0]; + assert.match(capabilityRevision(current), /^cp1-sha256:/); assert.equal(canonicalCapabilityBytes(current).toString(), `${JSON.stringify(current)}\n`); + assert.deepEqual(bindCapabilitySymbols({ nodes: [{ kind: "check", id: "verify", capability: "repo-verify" }] }, readCapabilityCatalog(root)), [{ nodeId: "verify", capability: "repo-verify", revision: capabilityRevision(current) }]); + const narrow = validateCapabilityGrants(grants(current, { output: { maxBytes: 8 }, maxMilliseconds: 10 }), current); assert.equal(narrow.output.maxBytes, 8); + assert.throws(() => validateCapabilityGrants(grants(current, { filesystem: { read: ["elsewhere"], write: [] } }), current), /exceeds/); +}); + +test("unknown untrusted or changed policies never launch, including same-size executable replacement", () => { + const root = temp(); writeCatalog(root); assert.throws(() => preflightCapability({ repoRoot: root, capabilityId: "missing" }), { code: "ELOOP_CAPABILITY_UNKNOWN" }); assert.throws(() => preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }), { code: "ELOOP_CAPABILITY_UNTRUSTED" }); trust(root); + const before = preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }); writeCatalog(root, [policy({ argv: [process.execPath, "-e", "process.exit(0)"] })]); assert.throws(() => runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate: `cm1-sha256:${"a".repeat(64)}`, preflight: before }), { code: "ELOOP_CAPABILITY_CHANGED" }); + writeCatalog(root, [policy({ filesystem: { read: ["src/input"], write: [] } })]); trust(root); const snap = preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }); + const original = snap.launch.snapshots.find((item) => item.path === process.execPath); const moved = `${process.execPath}.burnlist-test`; // immutable system executable cannot safely be renamed; exercise same snapshot API through a repo grant below. + assert.ok(original && !moved.includes("\0")); writeFileSync(join(root, "src", "input"), "b"); assert.throws(() => runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate: `cm1-sha256:${"b".repeat(64)}`, preflight: snap }), { code: "ELOOP_CAPABILITY_CHANGED" }); + const executable = join(root, "fake-verify"); writeFileSync(executable, "#!/bin/sh\nexit 0\n"); chmodSync(executable, 0o755); writeCatalog(root, [policy({ argv: [executable], filesystem: { read: ["src/input"], write: [] } })]); trust(root); const executableSnap = preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }); writeFileSync(executable, "#!/bin/sh\nexit 1\n"); assert.throws(() => runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate: `cm1-sha256:${"d".repeat(64)}`, preflight: executableSnap }), { code: "ELOOP_CAPABILITY_CHANGED" }); + writeFileSync(executable, "#!/bin/sh\nexit 0\n"); writeCatalog(root, [policy({ argv: [executable], cwd: "src", filesystem: { read: ["src/input"], write: [] } })]); trust(root); const cwdSnap = preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }); renameSync(join(root, "src"), join(root, "src-old")); mkdirSync(join(root, "src")); writeFileSync(join(root, "src", "input"), "b"); assert.throws(() => runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate: `cm1-sha256:${"e".repeat(64)}`, preflight: cwdSnap }), { code: "ELOOP_CAPABILITY_CHANGED" }); +}); + +test("executable snapshot rejects a symlink in any absolute ancestor", () => { + const root = temp(); const real = join(root, "real"); const link = join(root, "link"); const sub = join(real, "sub"); mkdirSync(real); mkdirSync(sub); symlinkSync(real, link); + const executable = join(link, "sub", "tool"); writeFileSync(join(sub, "tool"), "#!/bin/sh\nexit 0\n"); chmodSync(join(sub, "tool"), 0o755); + writeCatalog(root, [policy({ argv: [executable] })]); trust(root); + assert.throws(() => preflightCapability({ repoRoot: root, capabilityId: "repo-verify" }), /directory ancestor/); +}); + +test("sealed snapshots pin exact bytes across replacement or in-place writes and close explicitly", () => { + const root = temp(), executable = join(root, "tool"); + writeFileSync(executable, "old"); const snapshot = snapshotTarget({ root, path: executable }); + const held = holdSnapshot(snapshot); renameSync(executable, `${executable}.old`); writeFileSync(executable, "new"); + writeFileSync(`${executable}.old`, "bad"); + const bytes = Buffer.alloc(3); assert.equal(readSync(held.sealedDescriptor, bytes, 0, 3, 0), 3); + assert.equal(bytes.toString(), "old"); assert.notEqual(fstatSync(held.sealedDescriptor).ino, snapshot.identity.ino); + releaseSnapshot(held); + assert.throws(() => fstatSync(held.sealedDescriptor), { code: "EBADF" }); + assert.equal(readFileSync(executable, "utf8"), "new"); +}); + +test("private trust records use no-follow bounded descriptor reads and reject leaf or ancestor swaps", () => { + const root = temp(); writeCatalog(root); trust(root); assert.equal(readTrustedCapability({ repoRoot: root, capability: "repo-verify", policy: readCapabilityCatalog(root).capabilities[0] }).capability, "repo-verify"); + const record = localRecordPath(root, "capabilities", "repo-verify"); const moved = `${record}.old`; renameSync(record, moved); symlinkSync(moved, record); assert.throws(() => readTrustedCapability({ repoRoot: root, capability: "repo-verify", policy: readCapabilityCatalog(root).capabilities[0] }), /regular file|symbolic link|private/); + const ancestorRoot = temp(); writeCatalog(ancestorRoot); trust(ancestorRoot); const config = configRoot(ancestorRoot); renameSync(config, `${config}.old`); symlinkSync(`${config}.old`, config); assert.throws(() => readTrustedCapability({ repoRoot: ancestorRoot, capability: "repo-verify", policy: readCapabilityCatalog(ancestorRoot).capabilities[0] }), /directory ancestor/); +}); + +test("catalog rejects traversal, symlinks, environment attacks, oversize, and unsupported enforcement claims", () => { + assert.throws(() => parseCapabilityCatalog(Buffer.from(JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy({ cwd: "../outside" })] }))), /cwd/); + assert.throws(() => parseCapabilityCatalog(Buffer.from(JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy({ environment: { inherit: ["PATH"], set: { "BAD-NAME": "x" } } })] }))), /environment/); + assert.throws(() => parseCapabilityCatalog(Buffer.from(JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [{ ...policy(), guarantees: { filesystem: "enforced" } }] }))), /invalid capability policy/); + const root = temp(); writeFileSync(join(root, ".burnlist", "loop-capabilities.json"), "x".repeat(262145)); assert.throws(() => readCapabilityCatalog(root), /byte limit|invalid regular file/); + const linkRoot = temp(); writeFileSync(join(linkRoot, "catalog"), JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy()] })); symlinkSync(join(linkRoot, "catalog"), join(linkRoot, ".burnlist", "loop-capabilities.json")); assert.throws(() => readCapabilityCatalog(linkRoot), /regular file|symbolic/); + const ancestorRoot = temp(); renameSync(join(ancestorRoot, ".burnlist"), join(ancestorRoot, ".burnlist-old")); symlinkSync(join(ancestorRoot, ".burnlist-old"), join(ancestorRoot, ".burnlist")); assert.throws(() => readCapabilityCatalog(ancestorRoot), /directory ancestor/); +}); + +test("direct fake repo-verify has aggregate bounded output, deadline supervision, and closed capability evidence", async () => { + const root = temp(); writeCatalog(root, [policy({ argv: [process.execPath, "-e", "process.stdout.write('out');process.stderr.write('err')"] })]); trust(root); const inputCandidate = `cm1-sha256:${"c".repeat(64)}`; + const complete = await runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate }); assert.equal(complete.result.schema, "capability-evidence@1"); assert.equal(complete.result.outcome, "pass"); assert.equal(validateCapabilityEvidence(complete.result), complete.result); assert.match(complete.evidence.toString(), /candidate=cm1-sha256/); + writeCatalog(root, [policy({ argv: [process.execPath, "-e", "setInterval(()=>process.stdout.write('x'),1)"], output: { maxBytes: 8 }, maxMilliseconds: 1000 })]); trust(root); const noisy = await runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate }); assert.equal(noisy.result.truncated, true); assert.equal(noisy.result.outcome, "fail"); assert.ok(noisy.evidence.length < 128); + writeCatalog(root, [policy({ argv: [process.execPath, "-e", "setInterval(()=>{},1000)"], maxMilliseconds: 20 })]); trust(root); const timed = await runTrustedCapability({ repoRoot: root, capabilityId: "repo-verify", inputCandidate }); assert.equal(timed.result.timedOut, true); assert.equal(timed.result.outcome, "fail"); +}); + +test("trust creation refuses a symlinked local ancestor before recursive creation or publish", () => { + const root = temp(); writeCatalog(root); mkdirSync(join(root, "outside")); symlinkSync(join(root, "outside"), join(root, ".local")); + const current = readCapabilityCatalog(root).capabilities[0]; assert.throws(() => trustCapability({ repoRoot: root, capability: current, grants: grants(current) }), /unsafe trust directory/); +}); diff --git a/src/loops/capabilities/contract.mjs b/src/loops/capabilities/contract.mjs new file mode 100644 index 00000000..efa65529 --- /dev/null +++ b/src/loops/capabilities/contract.mjs @@ -0,0 +1,76 @@ +import { join, resolve } from "node:path"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { readSnapshotBytes } from "./snapshot.mjs"; + +export const CAPABILITY_CATALOG = ".burnlist/loop-capabilities.json"; +export const CAPABILITY_SCHEMA = "burnlist-loop-capabilities@1"; +export const MAX_CATALOG_BYTES = 262144; +export const GUARANTEE_LABELS = Object.freeze({ filesystem: "unsupported", process: "supervised", network: "unsupported", environment: "supervised", credentials: "unsupported", childSpawn: "unsupported", descendantContainment: "unsupported" }); +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const envName = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/u; +const repoPath = /^(?!\.git(?:\/|$))(?!\.local\/burnlist\/loop(?:\/|$))(?!.*(?:^|\/)\.\.?($|\/))[\x21-\x7e]+$/u; +const policyKeys = ["id", "argv", "cwd", "environment", "network", "filesystem", "output", "maxMilliseconds"]; +const grantKeys = ["argv", "cwd", "environment", "network", "filesystem", "output", "maxMilliseconds"]; + +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function fail(message, code = "ELOOP_CAPABILITY") { throw Object.assign(new Error(`Loop capability: ${message}`), { code }); } +function safeText(value, label, { empty = false, maximum = 4096 } = {}) { if (typeof value !== "string" || (!empty && !value) || Buffer.byteLength(value) > maximum || /[\0\r\n]/u.test(value)) fail(`invalid ${label}`); return value; } +function sortedUnique(values, valid, label, maximum = 128) { + if (!Array.isArray(values) || values.length > maximum || values.some((value) => typeof value !== "string" || !valid(value))) fail(`invalid ${label}`); + if (new Set(values).size !== values.length || values.some((value, index) => index && Buffer.compare(Buffer.from(values[index - 1]), Buffer.from(value)) >= 0)) fail(`${label} must be sorted and unique`); + return values; +} +function env(value) { + if (!exact(value, ["inherit", "set"])) fail("environment must be closed"); + sortedUnique(value.inherit, (name) => envName.test(name), "environment inherit", 64); + if (!value.inherit.includes("PATH")) fail("environment inherit must include PATH explicitly"); + if (!value.set || typeof value.set !== "object" || Array.isArray(value.set) || Object.keys(value.set).length > 64) fail("invalid environment set"); + const names = Object.keys(value.set); + if (names.some((name) => !envName.test(name) || value.inherit.includes(name)) || names.some((name) => !safeText(value.set[name], `environment ${name}`, { empty: true }))) fail("invalid environment set"); + return { inherit: [...value.inherit], set: Object.fromEntries(names.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b))).map((name) => [name, value.set[name]])) }; +} +function paths(value, label) { return sortedUnique(value, (path) => path === "." || repoPath.test(path), label, 256); } +function canonical(value, keys) { return Object.fromEntries(keys.map((key) => [key, value[key]])); } + +/** A capability is declarative policy, not a command recipe. */ +export function validateCapability(policy) { + if (!exact(policy, policyKeys) || !slug.test(policy.id)) fail("invalid capability policy"); + if (!Array.isArray(policy.argv) || policy.argv.length < 1 || policy.argv.length > 64) fail("argv must be a nonempty bounded array"); + for (const [index, value] of policy.argv.entries()) { safeText(value, `argv[${index}]`); if (index === 0 && !value.startsWith("/")) fail("argv[0] must be an absolute executable path"); } + if (typeof policy.cwd !== "string" || (policy.cwd !== "." && !repoPath.test(policy.cwd))) fail("cwd must be a safe repository path"); + const environment = env(policy.environment); + if (!["deny", "allow"].includes(policy.network)) fail("network must be deny or allow"); + if (!exact(policy.filesystem, ["read", "write"])) fail("filesystem must be closed"); + const filesystem = { read: paths(policy.filesystem.read, "filesystem read"), write: paths(policy.filesystem.write, "filesystem write") }; + if (filesystem.write.some((path) => path === "." || filesystem.read.includes(path))) fail("invalid filesystem grants"); + if (!exact(policy.output, ["maxBytes"]) || !Number.isSafeInteger(policy.output.maxBytes) || policy.output.maxBytes < 1 || policy.output.maxBytes > 1048576) fail("invalid output limit"); + if (!Number.isSafeInteger(policy.maxMilliseconds) || policy.maxMilliseconds < 1 || policy.maxMilliseconds > 86400000) fail("invalid maxMilliseconds"); + return { id: policy.id, argv: [...policy.argv], cwd: policy.cwd, environment, network: policy.network, filesystem, output: { maxBytes: policy.output.maxBytes }, maxMilliseconds: policy.maxMilliseconds }; +} +export function canonicalCapabilityBytes(policy) { return Buffer.from(`${JSON.stringify(validateCapability(policy))}\n`, "utf8"); } +export function capabilityRevision(policy) { return prefixed("cp1-sha256:", "capability-v1", [canonicalCapabilityBytes(policy)]); } + +/** Local grants are a separate closed object and can only narrow the repository policy. */ +export function validateCapabilityGrants(grants, policy) { + const source = validateCapability(policy); + if (!exact(grants, grantKeys) || JSON.stringify(grants.argv) !== JSON.stringify(source.argv) || grants.cwd !== source.cwd) fail("grants must preserve exact argv and cwd"); + const narrowed = { ...source, environment: env(grants.environment), network: grants.network, filesystem: { read: paths(grants.filesystem?.read, "grant filesystem read"), write: paths(grants.filesystem?.write, "grant filesystem write") }, output: grants.output, maxMilliseconds: grants.maxMilliseconds }; + if (!narrowed.environment.inherit.every((name) => source.environment.inherit.includes(name)) || Object.entries(narrowed.environment.set).some(([name, value]) => source.environment.set[name] !== value)) fail("environment grant exceeds policy"); + if (source.network === "deny" && narrowed.network !== "deny" || !["deny", "allow"].includes(narrowed.network)) fail("network grant exceeds policy"); + if (!narrowed.filesystem.read.every((path) => source.filesystem.read.includes(path)) || !narrowed.filesystem.write.every((path) => source.filesystem.write.includes(path))) fail("filesystem grant exceeds policy"); + if (!exact(narrowed.output, ["maxBytes"]) || !Number.isSafeInteger(narrowed.output.maxBytes) || narrowed.output.maxBytes < 1 || narrowed.output.maxBytes > source.output.maxBytes || !Number.isSafeInteger(narrowed.maxMilliseconds) || narrowed.maxMilliseconds < 1 || narrowed.maxMilliseconds > source.maxMilliseconds) fail("resource grant exceeds policy"); + return canonical(narrowed, policyKeys.slice(1)); +} +export function canonicalGrantBytes(grants, policy) { return Buffer.from(`${JSON.stringify(validateCapabilityGrants(grants, policy))}\n`, "utf8"); } + +export function parseCapabilityCatalog(bytes) { + let value; try { value = JSON.parse(Buffer.from(bytes).toString("utf8")); } catch { fail("catalog is not valid JSON"); } + if (!exact(value, ["schema", "capabilities"]) || value.schema !== CAPABILITY_SCHEMA || !Array.isArray(value.capabilities) || value.capabilities.length > 64) fail("catalog has an invalid schema"); + const capabilities = value.capabilities.map(validateCapability); + if (new Set(capabilities.map((policy) => policy.id)).size !== capabilities.length || capabilities.some((policy, index) => index && Buffer.compare(Buffer.from(capabilities[index - 1].id), Buffer.from(policy.id)) >= 0)) fail("capabilities must be sorted with unique ids"); + return { schema: CAPABILITY_SCHEMA, capabilities, bytes: Buffer.from(bytes), digest: rawSha256(bytes) }; +} +/** The source file and every existing ancestor are no-follow descriptor-checked. */ +export function readCapabilityCatalog(repoRoot) { const root = resolve(repoRoot); const path = join(root, CAPABILITY_CATALOG); const { bytes } = readSnapshotBytes({ root, path, maximum: MAX_CATALOG_BYTES }); return { ...parseCapabilityCatalog(bytes), path }; } +export function resolveCapability(catalog, id) { if (!slug.test(id)) fail("invalid capability id"); const policy = catalog?.capabilities?.find((entry) => entry.id === id); if (!policy) fail(`unknown capability ${id}`, "ELOOP_CAPABILITY_UNKNOWN"); return { policy, revision: capabilityRevision(policy), bytes: canonicalCapabilityBytes(policy) }; } +export function bindCapabilitySymbols(ir, catalog) { if (!ir || !Array.isArray(ir.nodes)) fail("invalid compiler IR"); return ir.nodes.filter((node) => node.kind === "check").map((node) => ({ nodeId: node.id, capability: node.capability, revision: resolveCapability(catalog, node.capability).revision })); } diff --git a/src/loops/capabilities/runner.mjs b/src/loops/capabilities/runner.mjs new file mode 100644 index 00000000..5c6ad770 --- /dev/null +++ b/src/loops/capabilities/runner.mjs @@ -0,0 +1,63 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import { lstatSync } from "node:fs"; +import { parse, resolve } from "node:path"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { GUARANTEE_LABELS, readCapabilityCatalog, resolveCapability } from "./contract.mjs"; +import { checkSnapshot, repoTarget, snapshotTarget } from "./snapshot.mjs"; +import { assertTrustedCapability } from "./trust.mjs"; + +function fail(message, code = "ELOOP_CAPABILITY_LAUNCH") { throw Object.assign(new Error(`Loop capability launch: ${message}`), { code }); } +function candidate(value) { if (typeof value !== "string" || !/^cm1-sha256:[a-f0-9]{64}$/u.test(value)) fail("candidate must be a canonical digest"); return value; } +function cleanEnvironment(policy) { const environment = {}; for (const name of policy.environment.inherit) if (Object.hasOwn(process.env, name)) environment[name] = process.env[name]; return Object.assign(environment, policy.environment.set); } +function launchSnapshots(repoRoot, policy) { + const cwd = repoTarget(repoRoot, policy.cwd); const snapshots = [snapshotTarget({ root: repoRoot, path: cwd, kind: "directory" })]; + // Executables may live outside the repository. Start at their filesystem + // root so a symlink anywhere in the absolute ancestry is rejected. + snapshots.push(snapshotTarget({ root: parse(policy.argv[0]).root, path: policy.argv[0], kind: "file" })); + for (const path of [...policy.filesystem.read, ...policy.filesystem.write]) { const target = repoTarget(repoRoot, path); const entry = lstatSync(target); snapshots.push(snapshotTarget({ root: repoRoot, path: target, kind: entry.isDirectory() ? "directory" : "file" })); } + return { cwd, snapshots }; +} +function assertSnapshots(snapshots) { for (const snapshot of snapshots) checkSnapshot(snapshot); } +function aggregateCapture(child, maximum) { + const chunks = { stdout: [], stderr: [] }; let total = 0; let truncated = false; let killed = false; + const terminate = () => { if (!killed) { killed = true; try { child.kill("SIGTERM"); } catch { /* direct process may already be gone */ } } }; + for (const name of ["stdout", "stderr"]) child[name]?.on("data", (chunk) => { + const bytes = Buffer.from(chunk); const remaining = Math.max(0, maximum - total); if (remaining) { chunks[name].push(bytes.subarray(0, remaining)); total += Math.min(remaining, bytes.length); } + if (bytes.length > remaining) { truncated = true; terminate(); } + }); + return { result() { return { stdout: Buffer.concat(chunks.stdout), stderr: Buffer.concat(chunks.stderr), total, truncated }; }, terminate }; +} + +export function preflightCapability({ repoRoot, capabilityId }) { + const root = resolve(repoRoot); const catalog = readCapabilityCatalog(root); const resolved = resolveCapability(catalog, capabilityId); const trust = assertTrustedCapability({ repoRoot: root, resolved }); + const policy = trust.grants; const launch = launchSnapshots(root, policy); + return { repoRoot: root, capabilityId, revision: resolved.revision, policyBytes: resolved.bytes, policy, grantsDigest: trust.grantsDigest, launch }; +} +export function revalidateCapability(preflight) { + assertSnapshots(preflight.launch.snapshots); + const fresh = preflightCapability({ repoRoot: preflight.repoRoot, capabilityId: preflight.capabilityId }); + if (fresh.revision !== preflight.revision || !fresh.policyBytes.equals(preflight.policyBytes) || fresh.grantsDigest !== preflight.grantsDigest) fail("capability changed after preflight", "ELOOP_CAPABILITY_CHANGED"); + assertSnapshots(fresh.launch.snapshots); return fresh; +} + +/** Executes a verified direct process only. Child/descendant containment is explicitly unsupported. */ +export function runTrustedCapability({ repoRoot, capabilityId, inputCandidate, preflight, spawn = nodeSpawn }) { + const current = revalidateCapability(preflight ?? preflightCapability({ repoRoot, capabilityId })); const input = candidate(inputCandidate); const [command, ...args] = current.policy.argv; + let child; try { child = spawn(command, args, { cwd: current.launch.cwd, env: cleanEnvironment(current.policy), shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); } catch (error) { return Promise.reject(error); } + const capture = aggregateCapture(child, current.policy.output.maxBytes); let timedOut = false; let forceTimer; + const deadline = setTimeout(() => { timedOut = true; capture.terminate(); forceTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already exited */ } }, 100); }, current.policy.maxMilliseconds); + return new Promise((resolveResult, reject) => { + child.once("error", (error) => { clearTimeout(deadline); clearTimeout(forceTimer); reject(error); }); + child.once("close", (code, signal) => { + clearTimeout(deadline); clearTimeout(forceTimer); const captured = capture.result(); const evidence = Buffer.concat([Buffer.from(`candidate=${input}\nstdout-bytes=${captured.stdout.length}\n`, "utf8"), captured.stdout, Buffer.from(`\nstderr-bytes=${captured.stderr.length}\n`, "utf8"), captured.stderr]); + const result = { schema: "capability-evidence@1", capability: capabilityId, capabilityRevision: current.revision, inputCandidate: input, outcome: code === 0 && !signal && !captured.truncated && !timedOut ? "pass" : "fail", exitCode: Number.isInteger(code) ? code : null, truncated: captured.truncated, timedOut, evidenceDigest: rawSha256(evidence), evidenceBytes: evidence.length, guaranteeLabels: GUARANTEE_LABELS }; + resolveResult({ result: Object.freeze(result), evidence: Buffer.from(evidence) }); + }); + }); +} + +export function validateCapabilityEvidence(value) { + const keys = ["schema", "capability", "capabilityRevision", "inputCandidate", "outcome", "exitCode", "truncated", "timedOut", "evidenceDigest", "evidenceBytes", "guaranteeLabels"]; + if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key)) || value.schema !== "capability-evidence@1" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.capability) || !/^cp1-sha256:[a-f0-9]{64}$/u.test(value.capabilityRevision) || !/^cm1-sha256:[a-f0-9]{64}$/u.test(value.inputCandidate) || !["pass", "fail"].includes(value.outcome) || !(Number.isInteger(value.exitCode) && value.exitCode >= 0 && value.exitCode <= 255 || value.exitCode === null) || typeof value.truncated !== "boolean" || typeof value.timedOut !== "boolean" || !/^sha256:[a-f0-9]{64}$/u.test(value.evidenceDigest) || !Number.isSafeInteger(value.evidenceBytes) || value.evidenceBytes < 0 || JSON.stringify(value.guaranteeLabels) !== JSON.stringify(GUARANTEE_LABELS)) fail("invalid capability evidence"); + if (value.outcome === "pass" && (value.exitCode !== 0 || value.truncated || value.timedOut)) fail("invalid passing evidence"); return value; +} diff --git a/src/loops/capabilities/snapshot.mjs b/src/loops/capabilities/snapshot.mjs new file mode 100644 index 00000000..9b54d4da --- /dev/null +++ b/src/loops/capabilities/snapshot.mjs @@ -0,0 +1,129 @@ +import { closeSync, constants, fsyncSync, fstatSync, lstatSync, mkdtempSync, openSync, readSync, rmdirSync, unlinkSync, writeSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const MAX_EXECUTABLE_BYTES = 256 * 1024 * 1024; +function fail(message) { throw Object.assign(new Error(`Loop capability launch: ${message}`), { code: "ELOOP_CAPABILITY_CHANGED" }); } +function identity(stat) { return { dev: stat.dev, ino: stat.ino, size: stat.size, mode: stat.mode, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs }; } +function same(left, right) { return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mode === right.mode && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs; } +function sameDirectory(left, right) { return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode; } +function nofollow(path, flags = constants.O_RDONLY, mode) { + return mode === undefined + ? openSync(path, flags | (Number.isInteger(constants.O_NOFOLLOW) ? constants.O_NOFOLLOW : 0)) + : openSync(path, flags | (Number.isInteger(constants.O_NOFOLLOW) ? constants.O_NOFOLLOW : 0), mode); +} +function regular(path, maximum) { + const entry = lstatSync(path); if (!entry.isFile() || entry.isSymbolicLink() || entry.size > maximum) fail(`invalid regular file ${path}`); + let fd; try { fd = nofollow(path); const opened = fstatSync(fd); if (!opened.isFile() || !same(identity(entry), identity(opened))) fail(`replaced file ${path}`); return { fd, stat: identity(opened) }; } catch (error) { if (error?.code === "ELOOP") fail(`symbolic link ${path}`); throw error; } +} +function ancestorSnapshots(root, target) { + const rootPath = resolve(root); const parts = target.slice(rootPath.length).split("/").filter(Boolean); const snapshots = []; + let current = rootPath; + for (const part of [null, ...parts.slice(0, -1)]) { + if (part) current = join(current, part); + const entry = lstatSync(current); if (!entry.isDirectory() || entry.isSymbolicLink()) fail(`invalid directory ancestor ${current}`); + snapshots.push({ path: current, identity: identity(entry) }); + } + return snapshots; +} +function checkAncestors(snapshots) { for (const item of snapshots) { const entry = lstatSync(item.path); if (!entry.isDirectory() || entry.isSymbolicLink() || !sameDirectory(item.identity, identity(entry))) fail(`directory changed ${item.path}`); } } +function digestRead(fd, size, maximum) { + if (size > maximum) fail("file exceeds launch snapshot limit"); const hash = createHash("sha256"); const buffer = Buffer.allocUnsafe(Math.min(65536, Math.max(1, size))); let offset = 0; + while (offset < size) { const amount = readSync(fd, buffer, 0, Math.min(buffer.length, size - offset), offset); if (amount <= 0) fail("file changed while snapshotting"); hash.update(buffer.subarray(0, amount)); offset += amount; } + return `sha256:${hash.digest("hex")}`; +} +function copyExact(source, target, size) { + const buffer = Buffer.allocUnsafe(Math.min(65536, Math.max(1, size))); let offset = 0; + while (offset < size) { + const amount = readSync(source, buffer, 0, Math.min(buffer.length, size - offset), offset); + if (amount <= 0) fail("file changed while sealing"); + let written = 0; + while (written < amount) { + const next = writeSync(target, buffer, written, amount - written); + if (next <= 0) fail("failed to seal launch bytes"); + written += next; + } + offset += amount; + } +} +function sealExact(opened, snapshot) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-launch-seal-")); + const path = join(directory, "bytes"); let target; let sealed; + try { + target = nofollow(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + copyExact(opened.fd, target, opened.stat.size); fsyncSync(target); closeSync(target); target = undefined; + sealed = nofollow(path); const state = fstatSync(sealed); + if (!state.isFile() || state.size !== opened.stat.size || digestRead(sealed, state.size, snapshot.maximum) !== snapshot.digest) + fail("sealed launch bytes do not match snapshot"); + unlinkSync(path); rmdirSync(directory); + return sealed; + } catch (error) { + if (sealed !== undefined) closeSync(sealed); + throw error; + } finally { + if (target !== undefined) closeSync(target); + try { unlinkSync(path); } catch (error) { if (error?.code !== "ENOENT") throw error; } + try { rmdirSync(directory); } catch (error) { if (error?.code !== "ENOENT") throw error; } + } +} + +/** Snapshot descriptor identities and bytes required to make a launch decision. */ +export function snapshotTarget({ root, path, kind = "file", maximum = MAX_EXECUTABLE_BYTES }) { + const target = resolve(path); const ancestors = ancestorSnapshots(root, target); checkAncestors(ancestors); + if (kind === "directory") { const entry = lstatSync(target); if (!entry.isDirectory() || entry.isSymbolicLink()) fail(`invalid directory ${target}`); const snapshot = { root: resolve(root), path: target, kind, ancestors, identity: identity(entry) }; checkSnapshot(snapshot); return snapshot; } + const opened = regular(target, maximum); + try { const digest = digestRead(opened.fd, opened.stat.size, maximum); const snapshot = { root: resolve(root), path: target, kind, ancestors, identity: opened.stat, digest, maximum }; checkSnapshot(snapshot); return snapshot; } + finally { closeSync(opened.fd); } +} +export function checkSnapshot(snapshot) { + checkAncestors(snapshot.ancestors); + if (snapshot.kind === "directory") { const entry = lstatSync(snapshot.path); if (!entry.isDirectory() || entry.isSymbolicLink() || !same(snapshot.identity, identity(entry))) fail(`directory changed ${snapshot.path}`); return snapshot; } + const opened = regular(snapshot.path, snapshot.maximum); + try { if (!same(snapshot.identity, opened.stat) || digestRead(opened.fd, opened.stat.size, snapshot.maximum) !== snapshot.digest) fail(`file changed ${snapshot.path}`); } + finally { closeSync(opened.fd); } + return snapshot; +} +/** + * Seal verified bytes into an unlinked private file before an external atomic commit. + * A retained descriptor for the source inode is not safe: an in-place writer can alter + * it without replacing the inode. The returned descriptor has no pathname and cannot + * be changed through a later source-path replacement or write. + */ +export function holdSnapshot(snapshot) { + if (snapshot?.kind !== "file") fail("only file snapshots can be held"); + checkAncestors(snapshot.ancestors); const opened = regular(snapshot.path, snapshot.maximum); + let descriptor; + try { + if (!same(snapshot.identity, opened.stat) || digestRead(opened.fd, opened.stat.size, snapshot.maximum) !== snapshot.digest) + fail(`file changed ${snapshot.path}`); + checkAncestors(snapshot.ancestors); + descriptor = sealExact(opened, snapshot); + if (!same(snapshot.identity, identity(fstatSync(opened.fd))) || digestRead(opened.fd, opened.stat.size, snapshot.maximum) !== snapshot.digest) + fail(`file changed ${snapshot.path}`); + checkAncestors(snapshot.ancestors); + return Object.freeze({ sealedDescriptor: descriptor, snapshot }); + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + throw error; + } finally { closeSync(opened.fd); } +} +export function releaseSnapshot(held) { + if (!held || !Number.isInteger(held.sealedDescriptor)) fail("invalid held snapshot"); + closeSync(held.sealedDescriptor); +} +/** Bounded descriptor read for private records; caller receives only verified bytes. */ +export function readSnapshotBytes({ root, path, maximum = 65536 }) { + const target = resolve(path); const ancestors = ancestorSnapshots(root, target); checkAncestors(ancestors); const opened = regular(target, maximum); + try { + const bytes = Buffer.allocUnsafe(opened.stat.size); let offset = 0; + while (offset < bytes.length) { const amount = readSync(opened.fd, bytes, offset, bytes.length - offset, offset); if (amount <= 0) fail(`file changed while reading ${target}`); offset += amount; } + checkAncestors(ancestors); const after = fstatSync(opened.fd); const pathAfter = lstatSync(target); + if (!same(opened.stat, identity(after)) || !same(opened.stat, identity(pathAfter))) fail(`file changed while reading ${target}`); + return { bytes, identity: opened.stat, ancestors }; + } finally { closeSync(opened.fd); } +} +export function repoTarget(repoRoot, value) { + const root = resolve(repoRoot); if (value !== "." && (!value || value.startsWith("/") || value.split("/").some((part) => !part || part === "." || part === ".."))) fail("invalid repository target"); + const path = resolve(root, value); if (!(path === root || path.startsWith(`${root}/`))) fail("repository target escapes root"); return path; +} diff --git a/src/loops/capabilities/trust.mjs b/src/loops/capabilities/trust.mjs new file mode 100644 index 00000000..e59c2985 --- /dev/null +++ b/src/loops/capabilities/trust.mjs @@ -0,0 +1,44 @@ +import { resolve } from "node:path"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { localRecordPath, writeLocalRecord } from "../config/store.mjs"; +import { canonicalCapabilityBytes, canonicalGrantBytes, capabilityRevision, validateCapability, validateCapabilityGrants } from "./contract.mjs"; +import { readSnapshotBytes } from "./snapshot.mjs"; + +const keys = ["schema", "capability", "revision", "policyDigest", "grants", "grantsDigest"]; +const id = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const revision = /^cp1-sha256:[a-f0-9]{64}$/u; +function fail(message, code = "ELOOP_CAPABILITY_TRUST") { throw Object.assign(new Error(`Loop capability trust: ${message}`), { code }); } +function exact(value, names) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === names.length && names.every((name) => Object.hasOwn(value, name)); } +function target(repoRoot, capability) { if (!id.test(capability)) fail("invalid capability id"); return localRecordPath(repoRoot, "capabilities", capability); } +function canonical(record) { return Buffer.from(`${JSON.stringify({ schema: record.schema, capability: record.capability, revision: record.revision, policyDigest: record.policyDigest, grants: record.grants, grantsDigest: record.grantsDigest })}\n`, "utf8"); } +function validate(record, policy) { + if (!exact(record, keys) || record.schema !== "burnlist-loop-capability-trust@1" || !id.test(record.capability) || !revision.test(record.revision) || !/^sha256:[a-f0-9]{64}$/u.test(record.policyDigest) || !/^sha256:[a-f0-9]{64}$/u.test(record.grantsDigest)) fail("record has invalid schema"); + const grants = validateCapabilityGrants(record.grants, policy); if (rawSha256(canonicalGrantBytes(grants, policy)) !== record.grantsDigest) fail("record grants digest mismatch"); + return { ...record, grants }; +} +function privateFile(path, stat) { if (!stat || (stat.mode & 0o077) !== 0) fail(`record is not private: ${path}`); } +/** CLI-owned atomic trust record. A new explicit trust replaces only this capability's record. */ +export function trustCapability({ repoRoot, capability, grants }) { + const policy = validateCapability(capability); const narrowed = validateCapabilityGrants(grants, policy); + const record = validate({ schema: "burnlist-loop-capability-trust@1", capability: policy.id, revision: capabilityRevision(policy), policyDigest: rawSha256(canonicalCapabilityBytes(policy)), grants: narrowed, grantsDigest: rawSha256(canonicalGrantBytes(narrowed, policy)) }, policy); + try { return writeLocalRecord({ repoRoot, collection: "capabilities", name: policy.id, value: record, validate: (value) => validate(value, policy), replaceInvalidCodes: ["ELOOP_CAPABILITY"] }); } + catch (error) { + if (error?.code === "ELOOP_CONFIG" && /unsafe config directory/u.test(error.message)) fail(error.message.replace(/^Loop local config: unsafe config directory /u, "unsafe trust directory ")); + throw error; + } +} + +/** No-follow, bounded record read with ancestor and leaf identity checks. */ +export function readTrustedCapability({ repoRoot, capability, policy }) { + const path = target(repoRoot, capability); let read; + try { read = readSnapshotBytes({ root: resolve(repoRoot), path, maximum: 65536 }); privateFile(path, read.identity); } + catch (error) { if (error?.code === "ENOENT") fail(`capability ${capability} is untrusted`, "ELOOP_CAPABILITY_UNTRUSTED"); throw error; } + let value; try { value = JSON.parse(read.bytes.toString("utf8")); } catch { fail("record is not JSON"); } + const record = validate(value, policy); if (!canonical(record).equals(read.bytes)) fail("record is not canonical"); return record; +} +export function assertTrustedCapability({ repoRoot, resolved }) { + let record; try { record = readTrustedCapability({ repoRoot, capability: resolved.policy.id, policy: resolved.policy }); } + catch (error) { if (error?.code === "ELOOP_CAPABILITY") fail(`capability ${resolved.policy.id} changed after trust`, "ELOOP_CAPABILITY_CHANGED"); throw error; } + if (record.revision !== resolved.revision || record.policyDigest !== rawSha256(resolved.bytes)) fail(`capability ${resolved.policy.id} changed after trust`, "ELOOP_CAPABILITY_CHANGED"); + return record; +} diff --git a/src/loops/completion/completion.mjs b/src/loops/completion/completion.mjs new file mode 100644 index 00000000..c86d5172 --- /dev/null +++ b/src/loops/completion/completion.mjs @@ -0,0 +1,127 @@ +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants, fsyncSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { checklistCompletion, findBurnlistDir, withLock } from "../../cli/lifecycle-moves.mjs"; +import { localIsoTimestamp, parsePlan, validatePlan } from "../../server/plan-model.mjs"; +import { publishOvenEvent } from "../../events/oven-event-store.mjs"; +import { assignmentStore } from "../assignment/store.mjs"; +import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; +import { parseItemRef } from "../assignment/selectors.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { runStore } from "../run/run-store.mjs"; + +const RECEIPT = "completion-receipt.json", INTENT = "completion-intent.json"; +const SHA = /^[a-f0-9]{64}$/u, RUN = /^run:[0-9a-z]{26}$/u, ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const ITEM = /^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/u; +function fail(message, code = "ELOOP_COMPLETION") { throw Object.assign(new Error(`Loop completion: ${message}`), { code }); } +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key, index) => Object.keys(value)[index] === key); } +function digest(bytes) { return createHash("sha256").update(bytes).digest("hex"); } +function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +function lifecycleIdentity(repoRoot, id, directory, expected = null) { + const root = resolve(repoRoot), paths = [join(root, "notes"), join(root, "notes", "burnlists"), join(root, "notes", "burnlists", "inprogress"), join(root, "notes", "burnlists", "inprogress", id)]; + if (resolve(directory) !== paths.at(-1)) fail("lifecycle directory escapes the repository", "ELOOP_LIFECYCLE_PATH"); + const identities = paths.map((path) => { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink()) fail("lifecycle path is not a real directory", "ELOOP_LIFECYCLE_PATH"); return { path, dev: stat.dev, ino: stat.ino }; }); + if (expected && identities.some((value, index) => value.dev !== expected[index].dev || value.ino !== expected[index].ino)) fail("lifecycle path changed during completion", "ELOOP_LIFECYCLE_PATH"); + return identities; +} +function atomicWrite(path, bytes) { + const temporary = join(dirname(path), `.${basename(path)}.${randomBytes(8).toString("hex")}.tmp`); let fd; + try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, path); syncDirectory(dirname(path)); } + finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } +} +function atomicPlanWrite(path, bytes) { + const temporary = join(dirname(path), `.${basename(path)}.${randomBytes(8).toString("hex")}.tmp`); let fd; + try { + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o666); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; + const staged = parsePlan(temporary); validateOrThrow(staged); renameSync(temporary, path); syncDirectory(dirname(path)); return staged; + } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } +} +function readCanonical(path, label) { + let stat; try { stat = lstatSync(path); } catch (error) { if (error?.code === "ENOENT") return null; throw error; } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2 || stat.size > 8192) fail(`${label} is corrupt`); + const bytes = readFileSync(path); let value; try { value = JSON.parse(bytes); } catch { fail(`${label} is corrupt`); } + if (!Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail(`${label} is not canonical`); return value; +} +function validRecord(value, label) { + const keys = ["schema", "runId", "itemRef", "assignmentId", "completedAt", "title", "planDigest"]; + if (!exact(value, keys) || value.schema !== "burnlist-loop-completion@1" || !RUN.test(value.runId) || !ITEM.test(value.itemRef) || !ASSIGNMENT.test(value.assignmentId) || !ISO.test(value.completedAt) || typeof value.title !== "string" || !value.title || Buffer.byteLength(value.title) > 4096 || !SHA.test(value.planDigest)) fail(`${label} is invalid`); + return Object.freeze({ ...value }); +} +function entryFor(record) { return `- ${parseItemRef(record.itemRef).itemId} | ${record.completedAt} | ${record.title}`; } +function activeRange(lines) { const start = lines.findIndex((line) => line.trim() === "## Active Checklist"); if (start < 0) fail("active checklist section is missing"); const end = lines.findIndex((line, index) => index > start && /^##\s+/u.test(line)); return { start, end: end < 0 ? lines.length : end }; } +function itemIdFor(line) { return line.match(/^- \[[ xX]\]\s+([^|]+?)(?:\s+\|\s+.+)?$/u)?.[1]?.trim() ?? null; } +function removeDetailBlock(lines, itemId) { + const { start, end } = activeRange(lines), escaped = itemId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), heading = new RegExp(`^###\\s+${escaped}(?:\\s*\\||\\s*$)`, "u"); + const at = lines.findIndex((line, index) => index > start && index < end && heading.test(line)); if (at < 0) return; + const next = lines.findIndex((line, index) => index > at && /^#{1,3}\s+/u.test(line)); lines.splice(at, (next < 0 ? end : next) - at); +} +function completedMarkdown(markdown, record) { + const itemId = parseItemRef(record.itemRef).itemId, lines = markdown.split(/\r?\n/u), { start, end } = activeRange(lines); + const at = lines.findIndex((line, index) => index > start && index < end && itemIdFor(line) === itemId); if (at < 0) fail(`active item ${itemId} was not found`); + const next = lines.findIndex((line, index) => index > at && index < end && itemIdFor(line)); lines.splice(at, (next < 0 ? end : next) - at); removeDetailBlock(lines, itemId); + const heading = lines.findIndex((line) => line.trim() === "## Completed"); + if (heading < 0) { while (lines.length && !lines.at(-1).trim()) lines.pop(); lines.push("", "## Completed", entryFor(record), ""); } + else { const after = lines.findIndex((line, index) => index > heading && /^##\s+/u.test(line)); lines.splice(after < 0 ? lines.length : after, 0, entryFor(record)); } + return `${lines.join("\n").replace(/\s*$/u, "")}\n`; +} +function validateOrThrow(plan) { const errors = validatePlan(plan).filter((issue) => issue.severity === "error"); if (errors.length) fail(errors.map((issue) => issue.message).join(" ")); } +function assertApplied(plan, record) { + const itemId = parseItemRef(record.itemRef).itemId; + if (plan.items.some((item) => item.id === itemId)) fail("receipt conflicts with an active item"); + const completed = plan.completed.filter((item) => item.id === itemId && item.completedAt === record.completedAt && item.title === record.title); + if (completed.length !== 1) fail("receipt does not match the current Burnlist"); +} +function assertCurrentAssignment({ repoRoot, planBytes, plan, authority, replay, store }) { + if (replay.projection.state !== "converged" || replay.projection.leaseHeld) fail("Run is not converged and idle", "ERUN_NOT_CONVERGED"); + if (replay.projection.itemRef !== authority.itemRef || authority.runId !== replay.projection.runId) fail("Run authority does not match its journal"); + const current = store.readCurrentRun?.(authority.itemRef); + if (!current || current.runId !== replay.projection.runId || current.assignmentId !== authority.assignmentId) fail("Run is superseded or not current for its assigned item", "ESTALE_RUN"); + const item = parseItemRef(authority.itemRef); let metadata, artifact; + try { metadata = validateAssignedItem(item.selector, locateItemSpan(planBytes, item.itemId)); artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); } + catch { fail("assigned item no longer matches the Run", "ESTALE_ASSIGNMENT"); } + if (metadata["Assignment-Id"] !== authority.assignmentId || artifact.assignmentId !== authority.assignmentId || artifact.itemRef !== authority.itemRef || artifact.assignedItemDigest !== authority.itemRevision || metadata.assignedDigest !== authority.itemRevision || artifact.executionRevision !== metadata["Execution-Revision"] || artifact.packageRevision !== metadata["Package-Revision"]) fail("assigned item no longer matches the Run", "ESTALE_ASSIGNMENT"); + const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); if (JSON.stringify(frozen.ir) !== JSON.stringify(replay.graph)) fail("Run graph does not match its sealed assignment"); + return { item, title: plan.items.find((entry) => entry.id === item.itemId)?.title }; +} +function assertAuthority(authority, runId) { + if (!authority || authority.schema !== "burnlist-loop-m12-run-authority@1" || authority.runId !== runId || !ITEM.test(authority.itemRef) || !ASSIGNMENT.test(authority.assignmentId) || !/^id1-sha256:[a-f0-9]{64}$/u.test(authority.itemRevision)) fail("sealed Run authority is unavailable"); return authority; +} +function recordFor({ authority, completedAt, title, planDigest }) { return Object.freeze({ schema: "burnlist-loop-completion@1", runId: authority.runId, itemRef: authority.itemRef, assignmentId: authority.assignmentId, completedAt, title, planDigest }); } +function publish(repoRoot, outcome) { if (!outcome.applied) return; try { publishOvenEvent(repoRoot, outcome.event); } catch (error) { console.warn(`Completed ${outcome.item.id}, but could not publish its observational Oven event: ${error.message}`); } } + +/** Complete exactly the sealed, converged Run under the normal lifecycle lock. */ +export function completeLoopRun({ repoRoot, runId, store = runStore(repoRoot), hooks = {} }) { + if (!RUN.test(runId) || !store?.read || !store?.readAuthority || !store?.readCurrentRun || !store?.paths?.pathFor) fail("invalid completion input"); + const replay = store.read(runId), authority = assertAuthority(store.readAuthority(runId), runId), item = parseItemRef(authority.itemRef), found = findBurnlistDir(repoRoot, item.burnlistId); + if (found.lifecycle.folder !== "inprogress") fail(`Burnlist ${item.burnlistId} is not inprogress`); + const lifecycle = lifecycleIdentity(repoRoot, item.burnlistId, found.dir); + const result = withLock(found.dir, () => { + lifecycleIdentity(repoRoot, item.burnlistId, found.dir, lifecycle); + const planPath = join(found.dir, "burnlist.md"), runDir = store.paths.pathFor(runId), receiptPath = join(runDir, RECEIPT), intentPath = join(runDir, INTENT), bytes = readFileSync(planPath), plan = parsePlan(planPath); + validateOrThrow(plan); const receipt = readCanonical(receiptPath, "completion receipt"); + if (receipt) { + const applied = validRecord(receipt, "completion receipt"); + if (applied.runId !== runId || applied.itemRef !== authority.itemRef || applied.assignmentId !== authority.assignmentId) fail("receipt belongs to another Run"); + assertApplied(plan, applied); + const pending = readCanonical(intentPath, "completion intent"); + if (pending) { + const intent = validRecord(pending, "completion intent"); + if (JSON.stringify(intent) !== JSON.stringify(applied)) fail("completion intent conflicts with its receipt"); + rmSync(intentPath, { force: true }); syncDirectory(runDir); + } + return { applied: false, item: plan.completed.find((entry) => entry.id === item.itemId), record: applied, event: null }; + } + let record = readCanonical(intentPath, "completion intent"); + if (record) { record = validRecord(record, "completion intent"); if (record.runId !== runId || record.itemRef !== authority.itemRef || record.assignmentId !== authority.assignmentId) fail("completion intent belongs to another Run"); const completed = plan.completed.filter((entry) => entry.id === item.itemId && entry.completedAt === record.completedAt && entry.title === record.title); if (completed.length === 1) { atomicWrite(receiptPath, Buffer.from(`${JSON.stringify(record)}\n`)); rmSync(intentPath, { force: true }); syncDirectory(runDir); return { applied: false, item: completed[0], record, event: null }; } } + const current = assertCurrentAssignment({ repoRoot, planBytes: bytes, plan, authority, replay, store }); if (!current.title) fail("Run item title is unavailable"); + const completedAt = record?.completedAt ?? localIsoTimestamp(), provisional = recordFor({ authority, completedAt, title: current.title, planDigest: "0".repeat(64) }), next = completedMarkdown(plan.markdown, provisional); + record = recordFor({ authority, completedAt, title: current.title, planDigest: digest(Buffer.from(next)) }); + const existingIntent = readCanonical(intentPath, "completion intent"); + if (existingIntent && validRecord(existingIntent, "completion intent").planDigest !== record.planDigest) fail("completion intent no longer matches the active Burnlist", "ESTALE_INTENT"); + if (!existingIntent) { atomicWrite(intentPath, Buffer.from(`${JSON.stringify(record)}\n`)); hooks.afterIntent?.(record); } + lifecycleIdentity(repoRoot, item.burnlistId, found.dir, lifecycle); const staged = atomicPlanWrite(planPath, Buffer.from(next)); hooks.afterPlan?.(record); atomicWrite(receiptPath, Buffer.from(`${JSON.stringify(record)}\n`)); hooks.afterReceipt?.(record); rmSync(intentPath, { force: true }); syncDirectory(runDir); + return { applied: true, item: { id: item.itemId, title: current.title }, record, event: checklistCompletion(item.burnlistId, { id: item.itemId, title: current.title }, record.completedAt, staged) }; + }); + publish(repoRoot, result); return Object.freeze({ runId, itemRef: authority.itemRef, assignmentId: authority.assignmentId, completedAt: result.record.completedAt, alreadyApplied: !result.applied }); +} diff --git a/src/loops/completion/completion.test.mjs b/src/loops/completion/completion.test.mjs new file mode 100644 index 00000000..a5b5561d --- /dev/null +++ b/src/loops/completion/completion.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { burnItem, closeLifecycle } from "../../cli/lifecycle-moves.mjs"; +import { prepareItemMutation, unassignLoopItem } from "../assignment/assignment.mjs"; +import { createProductionRun, createStoredProductionRunRunner } from "../run/binder.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "../run/run-test-fixtures.mjs"; +import { runStore } from "../run/run-store.mjs"; +import { completeLoopRun } from "./completion.mjs"; + +function context(t) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-completion-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + return { directory, repo, planPath: join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md") }; +} +function unassignedContext(t) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-completion-control-")), repo = join(directory, "repo"), planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); + mkdirSync(join(repo, "notes", "burnlists", "inprogress", "260722-001"), { recursive: true }); + writeFileSync(planPath, "# Runner\n\n## Active Checklist\n- [ ] L29 | Exercise production authority\n\n## Completed\n"); + t.after(() => rmSync(directory, { recursive: true, force: true })); return { repo, planPath }; +} +async function converged(context, runId = fixtureRunId) { + const store = runStore(context.repo); + await createProductionRun({ repoRoot: context.repo, store, itemRef: fixtureItemRef, runId }); + const counter = join(context.directory, `counter-${runId.slice(-4)}`); writeFileSync(counter, "0"); + const before = [process.env.BURNLIST_FAKE_COUNTER, process.env.BURNLIST_FAKE_OUTCOMES]; + process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,approve"; + try { assert.equal((await createStoredProductionRunRunner({ repoRoot: context.repo, store, runId }).run()).projection.state, "converged"); } + finally { + for (const [key, value] of [["BURNLIST_FAKE_COUNTER", before[0]], ["BURNLIST_FAKE_OUTCOMES", before[1]]]) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + } + return store; +} +function runPath(store, runId, name) { return join(store.paths.pathFor(runId), name); } +function completedLines(context) { return readFileSync(context.planPath, "utf8").match(/^- L29 \| .+$/gmu) ?? []; } + +test("completion burns one exact converged assignment, writes one receipt, and retries idempotently", async (t) => { + const value = context(t), store = await converged(value); + const first = completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }); + assert.equal(first.alreadyApplied, false); + assert.equal(readFileSync(value.planPath, "utf8").includes("- [ ] L29"), false); + assert.equal(completedLines(value).length, 1); + assert.equal(existsSync(runPath(store, fixtureRunId, "completion-receipt.json")), true); + writeFileSync(value.planPath, `${readFileSync(value.planPath, "utf8").trimEnd()}\n\nUnrelated lifecycle note.\n`); + const second = completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }); + assert.equal(second.alreadyApplied, true); assert.equal(completedLines(value).length, 1); +}); + +test("completion resumes every durable interruption cut without a duplicate ledger entry", async (t) => { + for (const cut of ["afterIntent", "afterPlan", "afterReceipt"]) { + const value = context(t), store = await converged(value); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store, hooks: { [cut]() { throw new Error(`cut:${cut}`); } } }), new RegExp(`cut:${cut}`, "u")); + const markdown = readFileSync(value.planPath, "utf8"); + if (cut === "afterIntent") assert.match(markdown, /- \[ \] L29/u); else assert.equal(completedLines(value).length, 1, cut); + const resumed = completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store: runStore(value.repo) }); + assert.equal(resumed.alreadyApplied, cut !== "afterIntent"); assert.equal(completedLines(value).length, 1, cut); + assert.equal(existsSync(runPath(store, fixtureRunId, "completion-intent.json")), false, cut); + } +}); + +test("a pending after-plan Loop intent blocks lifecycle close until its CLI retry seals the receipt", async (t) => { + const value = context(t), store = await converged(value); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store, hooks: { afterPlan() { throw new Error("cut:afterPlan"); } } }), /cut:afterPlan/u); + assert.throws(() => closeLifecycle(value.repo, "260722-001"), /pending Loop completion/u); + assert.equal(completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store: runStore(value.repo) }).alreadyApplied, true); + assert.doesNotThrow(() => closeLifecycle(value.repo, "260722-001")); +}); + +test("completion rejects a prepared Run and a stale assigned item", async (t) => { + const pending = context(t), pendingStore = runStore(pending.repo); + await createProductionRun({ repoRoot: pending.repo, store: pendingStore, itemRef: fixtureItemRef, runId: fixtureRunId }); + assert.throws(() => completeLoopRun({ repoRoot: pending.repo, runId: fixtureRunId, store: pendingStore }), /not converged/u); + + const stale = context(t), staleStore = await converged(stale); + writeFileSync(stale.planPath, readFileSync(stale.planPath, "utf8").replace("Exercise production authority", "Changed after Run creation")); + assert.throws(() => completeLoopRun({ repoRoot: stale.repo, runId: fixtureRunId, store: staleStore }), /assigned item no longer matches/u); + assert.match(readFileSync(stale.planPath, "utf8"), /- \[ \] L29/u); + + const superseded = context(t), firstStore = runStore(superseded.repo); + await createProductionRun({ repoRoot: superseded.repo, store: firstStore, itemRef: fixtureItemRef, runId: fixtureRunId }); + firstStore.terminalize(fixtureRunId, firstStore.acquireLease(fixtureRunId).lease, "cancelled", "test"); + const currentId = "run:01arz3ndektsv4rrffq69g5faw", currentStore = await converged(superseded, currentId); + assert.throws(() => completeLoopRun({ repoRoot: superseded.repo, runId: fixtureRunId, store: firstStore }), /not converged/u); + assert.equal(completeLoopRun({ repoRoot: superseded.repo, runId: currentId, store: currentStore }).alreadyApplied, false); +}); + +test("receipt and Run ambiguity evidence fail closed when corrupt, bounded, symlinked, or duplicate", async (t) => { + const value = context(t), store = await converged(value), receipt = runPath(store, fixtureRunId, "completion-receipt.json"); + symlinkSync(value.planPath, receipt); assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /receipt is corrupt/u); rmSync(receipt); + writeFileSync(receipt, "x".repeat(9000)); assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /receipt is corrupt/u); rmSync(receipt); + writeFileSync(receipt, "{}\n"); assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /receipt is invalid/u); rmSync(receipt); + completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }); + const record = JSON.parse(readFileSync(receipt, "utf8")); + writeFileSync(value.planPath, `${readFileSync(value.planPath, "utf8").trimEnd()}\n${`- L29 | ${record.completedAt} | ${record.title}`}\n`); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /Duplicate completed id/u); +}); + +test("completion rejects a symlinked lifecycle directory without touching its outside target", async (t) => { + const value = context(t), store = await converged(value), lifecycle = join(value.repo, "notes", "burnlists", "inprogress", "260722-001"), outside = join(value.directory, "outside"); + mkdirSync(outside); const outsidePlan = join(outside, "burnlist.md"), original = "# Outside\n\n## Active Checklist\n- [ ] L29 | Outside item\n\n## Completed\n"; + writeFileSync(outsidePlan, original); renameSync(lifecycle, `${lifecycle}.saved`); symlinkSync(outside, lifecycle, "dir"); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /lifecycle path is not a real directory/u); + assert.equal(readFileSync(outsidePlan, "utf8"), original); +}); + +test("direct burn cannot bypass Loop metadata, while a safe unassign restores direct bytes", async (t) => { + const value = context(t); + assert.throws(() => burnItem(value.repo, "260722-001", "L29"), /direct burn is blocked by Loop metadata/u); + const prepared = prepareItemMutation({ repoRoot: value.repo, itemRef: fixtureItemRef }); + const result = unassignLoopItem({ repoRoot: value.repo, itemRef: fixtureItemRef, prepared }); + assert.match(result.assignmentId, /^as1-sha256:/u); + assert.equal(burnItem(value.repo, "260722-001", "L29"), true); +}); + +test("a direct burn is byte-compatible after safe unassign against a never-assigned control", (t) => { + const control = unassignedContext(t), value = context(t), expected = "# Runner\n\n## Active Checklist\n- [ ] L29 | Exercise production authority\n\n## Completed\n", completedAt = "2026-07-24T12:00:00+00:00"; + assert.deepEqual(readFileSync(control.planPath, "utf8"), expected); + const prepared = prepareItemMutation({ repoRoot: value.repo, itemRef: fixtureItemRef }); + unassignLoopItem({ repoRoot: value.repo, itemRef: fixtureItemRef, prepared }); + assert.equal(burnItem(control.repo, "260722-001", "L29", false, { completedAt }), true); + assert.equal(burnItem(value.repo, "260722-001", "L29", false, { completedAt }), true); + assert.deepEqual(readFileSync(value.planPath, "utf8"), readFileSync(control.planPath, "utf8")); +}); diff --git a/src/loops/config/config.test.mjs b/src/loops/config/config.test.mjs new file mode 100644 index 00000000..7ec2cf7b --- /dev/null +++ b/src/loops/config/config.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { localRecordPath, configRoot, writeLocalRecord } from "./store.mjs"; +import { capabilityRevision, readCapabilityCatalog } from "../capabilities/contract.mjs"; +import { validateProfile } from "./profiles.mjs"; + +const root = resolve(new URL("../../..", import.meta.url).pathname); +const cli = join(root, "bin", "burnlist.mjs"); +const policy = { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; + +function fixture() { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-config-"))), repo = join(directory, "repo"); + mkdirSync(join(repo, ".burnlist"), { recursive: true }); mkdirSync(join(repo, "src")); execFileSync("git", ["init", "--quiet", repo]); + writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy] })}\n`); + const marker = join(directory, "child-ran"), binary = join(directory, "fake-codex"); + writeFileSync(binary, `#!/bin/sh\necho ran > ${JSON.stringify(marker)}\n`, { mode: 0o700 }); + return { directory, repo, binary, marker, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; +} +function run(context, args) { return spawnSync(process.execPath, [cli, ...args, "--repo", context.repo], { cwd: context.repo, encoding: "utf8" }); } +function profile(context, id, authority) { + const result = run(context, ["agent", "profile", "add", id, "--adapter", "builtin:codex-cli", "--binary", context.binary, "--model", "gpt-5.6-terra", "--effort", "medium", "--authority", authority]); + assert.equal(result.status, 0, result.stderr); return result; +} +function route(context, name, id) { const result = run(context, ["route", "set", name, "--profile", id]); assert.equal(result.status, 0, result.stderr); } +function trust(context) { + const grants = join(context.repo, "grants.json"); + writeFileSync(grants, `${JSON.stringify(Object.fromEntries(Object.entries(policy).filter(([key]) => key !== "id")))}\n`); + const revision = capabilityRevision(readCapabilityCatalog(context.repo).capabilities[0]); + const result = run(context, ["loop", "capability", "trust", "repo-verify", "--revision", revision, "--grants", grants]); + assert.equal(result.status, 0, result.stderr); +} + +test("M1 setup trusts only private configuration, exact routes, and repo-verify without launching", () => { + const context = fixture(); + try { + const empty = run(context, ["loop", "setup", "status"]); + assert.equal(empty.status, 1); assert.match(empty.stdout, /MISSING route implementation\.standard/u); assert.match(empty.stdout, /MISSING trust repo-verify/u); + const maker = profile(context, "maker", "write"); + assert.equal(readFileSync(localRecordPath(context.repo, "profiles", "maker"), "utf8"), maker.stdout); + assert.equal(lstatSync(localRecordPath(context.repo, "profiles", "maker")).mode & 0o777, 0o600); + assert.equal(lstatSync(configRoot(context.repo)).mode & 0o777, 0o700); + route(context, "implementation.standard", "maker"); profile(context, "reviewer", "read"); route(context, "review.strong", "reviewer"); trust(context); + const status = run(context, ["loop", "setup", "status"]); + assert.equal(status.status, 0, status.stderr); assert.equal(status.stdout, "Loop setup: ready\n"); + assert.equal(existsSync(context.marker), false); + } finally { context.cleanup(); } +}); + +test("fresh repositories receive actionable capability and profile guidance", () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-fresh-"))), repo = join(directory, "repo"); + try { + mkdirSync(repo, { recursive: true }); execFileSync("git", ["init", "--quiet", repo]); + const inspect = spawnSync(process.execPath, [cli, "loop", "capability", "inspect", "repo-verify", "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(inspect.status, 1); assert.match(inspect.stderr, /create \.burnlist\/loop-capabilities\.json/u); + const setup = spawnSync(process.execPath, [cli, "loop", "setup", "status", "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(setup.status, 1); assert.match(setup.stdout, /Review Loop capability example/u); + const invalid = spawnSync(process.execPath, [cli, "agent", "profile", "add", "maker", "--adapter", "builtin:codex-cli", "--binary", process.execPath, "--model", "unknown", "--effort", "fast", "--authority", "write", "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(invalid.status, 1); assert.match(invalid.stderr, /model must be one of:.*gpt-5\.6-terra/u); + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + +test("M1 setup fails closed for duplicate, malformed, wrong-authority, and untrusted configuration", () => { + for (const scenario of ["duplicate", "wrong-authority", "malformed", "untrusted"]) { + const context = fixture(); + try { + profile(context, "maker", "write"); route(context, "implementation.standard", "maker"); + if (scenario === "duplicate") route(context, "review.strong", "maker"); + else { profile(context, "reviewer", scenario === "wrong-authority" ? "write" : "read"); route(context, "review.strong", "reviewer"); } + if (scenario === "malformed") writeFileSync(localRecordPath(context.repo, "profiles", "reviewer"), "{}\n", { mode: 0o600 }); + if (scenario !== "untrusted") trust(context); + const result = run(context, ["loop", "setup", "status"]); + assert.equal(result.status, 1, scenario); assert.match(result.stdout, /MISSING (routing|profile|trust)/u); assert.equal(existsSync(context.marker), false); + } finally { context.cleanup(); } + } +}); + +test("legacy Docker commands are unsupported and configuration writes fail closed when public", () => { + const context = fixture(); + try { + for (const args of [["agent", "controller", "add", "host"], ["agent", "preflight", "maker"]]) { + const result = run(context, args); assert.equal(result.status, 2); assert.match(result.stderr, /Usage: burnlist agent profile add/u); + } + profile(context, "maker", "write"); chmodSync(configRoot(context.repo), 0o755); + const result = run(context, ["route", "set", "implementation.standard", "--profile", "maker"]); + assert.equal(result.status, 1); assert.match(result.stderr, /unsafe config directory/u); + } finally { context.cleanup(); } +}); + +test("setup read fails closed after config-v1 privacy drifts without mutation", () => { + const context = fixture(); + try { + profile(context, "maker", "write"); route(context, "implementation.standard", "maker"); profile(context, "reviewer", "read"); route(context, "review.strong", "reviewer"); trust(context); + const profilePath = localRecordPath(context.repo, "profiles", "maker"), before = readFileSync(profilePath); + chmodSync(configRoot(context.repo), 0o755); + const result = run(context, ["loop", "setup", "status"]); + assert.equal(result.status, 1); assert.match(result.stdout, /unsafe config directory/u); + assert.deepEqual(readFileSync(profilePath), before); assert.equal(existsSync(context.marker), false); + } finally { context.cleanup(); } +}); + +test("private profile publication is atomic when publication is interrupted", () => { + const context = fixture(); + try { + const value = { schema: "burnlist-loop-agent-profile@1", id: "maker", adapter: "builtin:codex-cli", binary: context.binary, model: "gpt-5.6-terra", effort: "medium", authority: "write" }; + assert.throws(() => writeLocalRecord({ repoRoot: context.repo, collection: "profiles", name: "maker", value, validate: validateProfile, hooks: { beforeRename() { throw new Error("cut"); } } }), /cut/u); + assert.equal(existsSync(localRecordPath(context.repo, "profiles", "maker")), false); + } finally { context.cleanup(); } +}); diff --git a/src/loops/config/profiles.mjs b/src/loops/config/profiles.mjs new file mode 100644 index 00000000..32dba252 --- /dev/null +++ b/src/loops/config/profiles.mjs @@ -0,0 +1,50 @@ +import { isClosedObject, readLocalRecord, writeLocalRecord } from "./store.mjs"; +import { CODEX_MODELS, REASONING_EFFORTS, validateAgentProfile } from "../agents/profile.mjs"; +import { parse } from "node:path"; +import { snapshotTarget } from "../capabilities/snapshot.mjs"; + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const route = /^(?:implementation\.standard|review\.strong)$/u; +const ROUTE_KEYS = ["schema", "route", "profile"]; + +function fail(message) { throw Object.assign(new Error(`Loop profile: ${message}`), { code: "ELOOP_PROFILE" }); } +function profileName(value, label = "profile slug") { if (typeof value !== "string" || !slug.test(value)) fail(`invalid ${label}`); return value; } + +export function validateProfile(value) { + try { return validateAgentProfile(value); } catch (error) { fail(error.message.replace(/^Loop agent: /u, "")); } +} + +export function validateRoute(value) { + if (!isClosedObject(value, ROUTE_KEYS) || value.schema !== "burnlist-loop-route@1" || !route.test(value.route)) fail("route record has invalid schema"); + return { schema: value.schema, route: value.route, profile: profileName(value.profile) }; +} + +export function saveProfile({ repoRoot, slug: name, adapter, binary, model, effort, authority }) { + const value = { schema: "burnlist-loop-agent-profile@1", id: name, adapter, binary, model, effort, authority }; + return writeLocalRecord({ repoRoot, collection: "profiles", name: profileName(name), value, validate: validateProfile }); +} +export function readProfile({ repoRoot, slug: name }) { return readLocalRecord({ repoRoot, collection: "profiles", name: profileName(name), validate: validateProfile }); } +export function saveRoute({ repoRoot, route: name, profile }) { + const value = { schema: "burnlist-loop-route@1", route: name, profile }; + return writeLocalRecord({ repoRoot, collection: "routes", name: String(name).replace(".", "-"), value, validate: validateRoute }); +} +export function readRoute({ repoRoot, route: name }) { if (!route.test(name)) fail("unknown route"); return readLocalRecord({ repoRoot, collection: "routes", name: name.replace(".", "-"), validate: validateRoute }); } + +/** + * No-cost local inspection only: no model invocation, child process, or write. + * Availability is not technical proof and therefore never makes setup ready. + */ +export function doctorProfile({ repoRoot, slug: name }) { + const profile = readProfile({ repoRoot, slug: name }); + try { + const snapshot = snapshotTarget({ root: parse(profile.binary).root, path: profile.binary, maximum: 64 * 1024 * 1024 }); + if ((snapshot.identity.mode & 0o111) === 0) return { available: false, ready: false, profile, reason: "binary is not executable" }; + return { available: true, ready: false, profile, executableDigest: snapshot.digest, reason: "technical guarantees are unavailable; no model probe was run" }; + } catch (error) { return { available: false, ready: false, profile, reason: error?.message || "binary inspection failed" }; } +} + +export const requiredRoutes = Object.freeze([ + { route: "implementation.standard", authority: "write" }, + { route: "review.strong", authority: "read" }, +]); +export { CODEX_MODELS, REASONING_EFFORTS }; diff --git a/src/loops/config/setup.mjs b/src/loops/config/setup.mjs new file mode 100644 index 00000000..69c819ac --- /dev/null +++ b/src/loops/config/setup.mjs @@ -0,0 +1,39 @@ +import { assertTrustedCapability } from "../capabilities/trust.mjs"; +import { readCapabilityCatalog, resolveCapability } from "../capabilities/contract.mjs"; +import { resolveConfiguredStageOneRoutes } from "../agents/profile.mjs"; +import { readProfile, readRoute, requiredRoutes } from "./profiles.mjs"; + +function profileCommand(slug = "", authority = "read|write") { return `burnlist agent profile add ${slug} --adapter builtin:codex-cli --binary --model --effort --authority ${authority}`; } +function routeCommand(route) { return `burnlist route set ${route} --profile `; } +function record(kind, id, detail, remedy) { return { kind, id, detail, remedy }; } +function clean(error) { return String(error?.message ?? error).replace(/^Loop (?:agent|local config|capability trust|capability): /u, ""); } + +/** Strictly read-only: configuration and trust readiness only; no child is launched. */ +export function setupStatus({ repoRoot } = {}) { + const failures = [], profiles = [], routes = {}; + for (const expected of requiredRoutes) { + let assigned; + try { assigned = readRoute({ repoRoot, route: expected.route }); routes[expected.route] = assigned.profile; } + catch (error) { failures.push(record("route", expected.route, clean(error), routeCommand(expected.route))); continue; } + let profile; + try { profile = readProfile({ repoRoot, slug: assigned.profile }); profiles.push(profile); } + catch (error) { failures.push(record("profile", assigned.profile, clean(error), profileCommand(assigned.profile, expected.authority))); continue; } + } + if (Object.keys(routes).length === requiredRoutes.length && profiles.length === requiredRoutes.length) { + try { resolveConfiguredStageOneRoutes({ profiles, routes }); } + catch (error) { failures.push(record("routing", "stage-one", clean(error), "repair the named profile or route and run burnlist loop setup status again")); } + } + let resolved; + try { resolved = resolveCapability(readCapabilityCatalog(repoRoot), "repo-verify"); } + catch (error) { failures.push(record("capability", "repo-verify", clean(error), "create .burnlist/loop-capabilities.json from the Review Loop capability example, then run burnlist loop capability inspect repo-verify")); } + if (resolved) { + try { assertTrustedCapability({ repoRoot, resolved }); } + catch (error) { failures.push(record("trust", "repo-verify", clean(error), `burnlist loop capability trust repo-verify --revision ${resolved.revision} --grants `)); } + } + return { ready: failures.length === 0, failures }; +} + +export function renderSetupStatus(status) { + if (status.ready) return "Loop setup: ready\n"; + return ["Loop setup: incomplete", ...status.failures.map((failure) => `MISSING ${failure.kind} ${failure.id}: ${failure.detail}\nREMEDIATION: ${failure.remedy}`), ""].join("\n"); +} diff --git a/src/loops/config/store.mjs b/src/loops/config/store.mjs new file mode 100644 index 00000000..fb8557af --- /dev/null +++ b/src/loops/config/store.mjs @@ -0,0 +1,155 @@ +import { randomBytes } from "node:crypto"; +import { closeSync, constants, fchmodSync, fsyncSync, lstatSync, mkdirSync, openSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { checkSnapshot, readSnapshotBytes, snapshotTarget } from "../capabilities/snapshot.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; + +const NAME = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u; +export const MAX_LOCAL_RECORD_BYTES = 65536; + +function fail(message, code = "ELOOP_CONFIG") { throw Object.assign(new Error(`Loop local config: ${message}`), { code }); } +function sync(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key, index) => Object.keys(value)[index] === key); } +function boundedName(value, label) { if (typeof value !== "string" || !NAME.test(value) || Buffer.byteLength(value) > 128) fail(`invalid ${label}`); return value; } + +export const LOOP_CONFIG_SCHEMA = "burnlist-loop-local-config@1"; +export const configRoot = (repoRoot) => join(resolve(repoRoot), ".local", "burnlist", "loop", "config-v1"); + +/** Shared ancestors may use normal repository permissions; config-v1 and descendants are private. */ +function secureDirectory(repoRoot, path) { + const root = resolve(repoRoot), target = resolve(path), rel = relative(root, target); + if (rel === "" || isAbsolute(rel) || rel.split(sep).includes("..")) fail("config directory escapes repository"); + let current = root, privateZone = false; + for (const part of rel.split(sep)) { + current = join(current, part); privateZone ||= part === "config-v1"; + try { + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || (privateZone && (stat.mode & 0o777) !== 0o700)) fail(`unsafe config directory ${current}`); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + mkdirSync(current, { mode: privateZone ? 0o700 : 0o755 }); + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || (privateZone && (stat.mode & 0o777) !== 0o700)) fail(`unsafe config directory ${current}`); + try { sync(dirname(current)); } catch { /* directory fsync is unsupported on some platforms */ } + } + } + return snapshotTarget({ root, path: target, kind: "directory" }); +} + +/** Read-side counterpart to secureDirectory: inspect only, never create. */ +function assertPrivateConfigRoot(repoRoot) { + const root = resolve(repoRoot), target = configRoot(root), rel = relative(root, target); + if (rel === "" || isAbsolute(rel) || rel.split(sep).includes("..")) fail("config directory escapes repository"); + let current = root; + for (const part of rel.split(sep)) { + current = join(current, part); + const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink() || (part === "config-v1" && (stat.mode & 0o777) !== 0o700)) fail(`unsafe config directory ${current}`); + } +} + +function filePath(repoRoot, collection, name) { + boundedName(collection, "collection"); boundedName(name, "record name"); + return join(configRoot(repoRoot), `${collection}--${name}.json`); +} +function privateFile(path, stat) { + // readSnapshotBytes already proved a no-follow regular file; its identity is plain data. + if (!stat || (stat.mode & 0o077) !== 0) fail(`unsafe config record ${path}`); +} +function canonicalBytes(value, validate) { + const canonical = Buffer.from(`${JSON.stringify(validate(value))}\n`, "utf8"); + if (canonical.length > MAX_LOCAL_RECORD_BYTES) fail("record exceeds byte limit"); + return canonical; +} +function checkSameDirectory(snapshot) { + const stat = lstatSync(snapshot.path); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== snapshot.identity.dev || stat.ino !== snapshot.identity.ino || stat.mode !== snapshot.identity.mode) fail(`directory identity changed ${snapshot.path}`); +} + +export function localRecordPath(repoRoot, collection, name) { return filePath(repoRoot, collection, name); } + +export function readLocalRecord({ repoRoot, collection, name, validate }) { + const path = filePath(repoRoot, collection, name); let read; + try { assertPrivateConfigRoot(repoRoot); read = readSnapshotBytes({ root: resolve(repoRoot), path, maximum: MAX_LOCAL_RECORD_BYTES }); } + catch (error) { + if (error?.code === "ENOENT") throw Object.assign(new Error(`Loop local config: ${collection}/${name} is missing`), { code: "ELOOP_CONFIG_MISSING" }); + throw error; + } + privateFile(path, read.identity); + let parsed; try { parsed = JSON.parse(read.bytes.toString("utf8")); } catch { fail(`${collection}/${name} is not JSON`); } + const value = validate(parsed), canonical = canonicalBytes(value, validate); + if (!canonical.equals(read.bytes)) fail(`${collection}/${name} is not canonical`); + return value; +} + +/** Copy a configured executable into the fixed private config directory before launch. */ +export function createPrivateExecutableSnapshot({ repoRoot, sourcePath, expectedDigest }) { + const root = resolve(repoRoot), config = configRoot(root); secureDirectory(root, config); + return withDirectoryLock({ lockPath: join(config, ".config.lock"), errorFactory: () => fail("configuration lock unavailable"), fn() { + const parent = secureDirectory(root, config); + const source = readSnapshotBytes({ root: dirname(resolve(sourcePath)), path: sourcePath, maximum: 64 * 1024 * 1024 }); + const digest = rawSha256(source.bytes); + if (digest !== expectedDigest) fail("configured executable changed before private snapshot", "ELOOP_CONFIG_QUARANTINED"); + const token = randomBytes(12).toString("hex"), staging = join(config, `.controller-${token}`), temporary = join(staging, "controller.tmp"), path = join(staging, "controller.exec"); let fd, published = false; + try { + mkdirSync(staging, { mode: 0o700 }); checkSameDirectory(parent); + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o700); + writeFileSync(fd, source.bytes); fchmodSync(fd, 0o500); fsyncSync(fd); closeSync(fd); fd = undefined; + renameSync(temporary, path); published = true; sync(staging); checkSameDirectory(parent); sync(config); + const snapshot = snapshotTarget({ root: staging, path, maximum: 64 * 1024 * 1024 }); + if (snapshot.digest !== digest || (snapshot.identity.mode & 0o777) !== 0o500) fail("private executable snapshot postcondition failed", "ELOOP_CONFIG_QUARANTINED"); + return { path, digest, snapshot, staging }; + } catch (error) { + if (published) fail(`private executable snapshot is quarantined: ${error.message}`, "ELOOP_CONFIG_QUARANTINED"); + throw error; + } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); if (!published) rmSync(staging, { recursive: true, force: true }); } + } }); +} + +export function removePrivateExecutableSnapshot(snapshot) { + try { + checkSnapshot(snapshot); + const staging = dirname(snapshot.path); + unlinkSync(snapshot.path); sync(staging); rmdirSync(staging); sync(dirname(staging)); + } catch (error) { fail(`private executable snapshot is quarantined: ${error.message}`, "ELOOP_CONFIG_QUARANTINED"); } +} + + +/** + * All records share one fixed private directory. Cooperating writers are + * serialized by its identity-bound lock; hostile same-user replacement still + * requires OS isolation and is only detected at boundaries. + */ +export function writeLocalRecord({ repoRoot, collection, name, value, validate, hooks = {}, replaceInvalidCodes = [] }) { + const root = resolve(repoRoot), path = filePath(root, collection, name), canonical = canonicalBytes(value, validate); + const config = configRoot(root); secureDirectory(root, config); + return withDirectoryLock({ lockPath: join(config, ".config.lock"), errorFactory: () => fail("configuration lock unavailable"), fn() { + const targetParent = secureDirectory(root, config); + try { + const current = readLocalRecord({ repoRoot: root, collection, name, validate }); + if (canonicalBytes(current, validate).equals(canonical)) return current; + } catch (error) { if (error?.code !== "ELOOP_CONFIG_MISSING" && !replaceInvalidCodes.includes(error?.code)) throw error; } + const token = randomBytes(12).toString("hex"), temporary = join(config, `.${collection}--${name}.${token}.tmp`); let fd, published = false; + try { + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); + writeFileSync(fd, canonical); fsyncSync(fd); closeSync(fd); fd = undefined; + if ((lstatSync(temporary).mode & 0o077) !== 0) fail("temporary config record is not private"); + hooks.beforePublish?.({ path, temporary }); + checkSameDirectory(targetParent); + hooks.beforeRename?.({ path, temporary }); + checkSameDirectory(targetParent); + renameSync(temporary, path); published = true; + hooks.afterPublish?.({ path }); + checkSameDirectory(targetParent); + const current = readLocalRecord({ repoRoot: root, collection, name, validate }); + if (!canonicalBytes(current, validate).equals(canonical)) fail("published record postcondition failed"); + sync(dirname(path)); return current; + } catch (error) { + if (published) fail(`publication postcondition failed; configuration is quarantined: ${error.message}`, "ELOOP_CONFIG_QUARANTINED"); + throw error; + } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } }); +} + +export function isClosedObject(value, keys) { return exact(value, keys); } diff --git a/src/loops/contracts/agent-result.mjs b/src/loops/contracts/agent-result.mjs new file mode 100644 index 00000000..3e046dcc --- /dev/null +++ b/src/loops/contracts/agent-result.mjs @@ -0,0 +1,97 @@ +import { rawSha256 } from "../dsl/hash.mjs"; +import { bindingsMatch, DIGESTS, exact, fail, identity, parseBoundedObject, parseResultBytes, SLUG, sortedUnique } from "./contract.mjs"; +import { nextOpenFindings, validateFindingSet } from "./finding.mjs"; + +const RESULT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "outcome", "findings", "resolvedFindingIds"]; +const INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "instructionDigest", "instructionBytes", "candidateContext", "reviewerEvidence"]; +const AUTHORITY_KEYS = ["schema", "state", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "inputSchema", "inputDigest", "inputByteLength"]; +const OUTCOMES = Object.freeze({ task: new Set(["complete"]), review: new Set(["approve", "reject", "escalate"]) }); +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; + +function base64(value, label, maximum) { + if (typeof value !== "string" || !BASE64.test(value)) fail(`invalid ${label}`); + const bytes = Buffer.from(value, "base64"); + if (bytes.toString("base64") !== value || bytes.length > maximum) fail(`invalid ${label}`); + return bytes; +} +function canonicalEnvelope(value) { + return Buffer.from(`${JSON.stringify({ + schema: value.schema, runId: value.runId, nodeId: value.nodeId, attempt: value.attempt, claimId: value.claimId, + assignmentId: value.assignmentId, invocationId: value.invocationId, recipeRevision: value.recipeRevision, + policyRevision: value.policyRevision, inputCandidate: value.inputCandidate, itemRevision: value.itemRevision, + instructionDigest: value.instructionDigest, instructionBytes: value.instructionBytes, candidateContext: value.candidateContext, + reviewerEvidence: value.reviewerEvidence, + })}\n`, "utf8"); +} + +/** Builds the exact adapter stdin/prompt payload; this digest is persisted before dispatch by the runner. */ +export function createInvocationInput(value) { + if (!exact(value, INPUT_KEYS) || value.schema !== "burnlist-loop-invocation-input@1") fail("invalid invocation input"); + identity(value, "invocation input"); + if (!DIGESTS.item.test(value.itemRevision) || !DIGESTS.raw.test(value.instructionDigest) + || !SLUG.test(value.nodeId) || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 + || !sortedUnique(value.reviewerEvidence) || !value.reviewerEvidence.every((ref) => DIGESTS.artifact.test(ref))) fail("invalid invocation evidence"); + const instruction = base64(value.instructionBytes, "instruction bytes", 65_536); + const context = base64(value.candidateContext, "candidate context", 65_536); + if (!instruction.length || !context.length) fail("empty invocation input"); + if (rawSha256(instruction) !== value.instructionDigest) fail("instruction digest does not match frozen bytes"); + const bytes = canonicalEnvelope(value); + if (bytes.length > 262_144) fail("invocation input exceeds bounds"); + return Object.freeze({ value: Object.freeze({ ...value, reviewerEvidence: Object.freeze([...value.reviewerEvidence]) }), bytes, digest: rawSha256(bytes) }); +} + +function canonicalAuthority(value) { + return Buffer.from(`${JSON.stringify({ + schema: value.schema, state: value.state, runId: value.runId, nodeId: value.nodeId, attempt: value.attempt, + claimId: value.claimId, assignmentId: value.assignmentId, invocationId: value.invocationId, + recipeRevision: value.recipeRevision, policyRevision: value.policyRevision, inputCandidate: value.inputCandidate, + itemRevision: value.itemRevision, inputSchema: value.inputSchema, inputDigest: value.inputDigest, + inputByteLength: value.inputByteLength, + })}\n`); +} + +/** Closed journal-ready proof that exact invocation bytes were prepared before dispatch. */ +export function createDispatchAuthority(value) { + if (!exact(value, AUTHORITY_KEYS) || value.schema !== "burnlist-loop-dispatch-authority@1" || value.state !== "prepared-before-dispatch" + || value.inputSchema !== "burnlist-loop-invocation-input@1" || !DIGESTS.item.test(value.itemRevision) + || !DIGESTS.raw.test(value.inputDigest) || !Number.isInteger(value.inputByteLength) || value.inputByteLength < 2 || value.inputByteLength > 262_144) fail("invalid dispatch authority"); + identity(value, "dispatch authority"); + const bytes = canonicalAuthority(value); + return Object.freeze({ value: Object.freeze({ ...value }), bytes, digest: rawSha256(bytes) }); +} + +export function validateDispatchAuthority(bytes) { + const raw = Buffer.from(bytes); + const built = createDispatchAuthority(parseBoundedObject(raw, { maximumBytes: 16_384, maximumDepth: 1, label: "dispatch authority" })); + if (!built.bytes.equals(raw)) fail("dispatch authority is not canonical"); + return built; +} + +export function validateInvocationInput(bytes, authorityBytes) { + const raw = Buffer.from(bytes); + const authority = validateDispatchAuthority(authorityBytes); + const value = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 2, label: "invocation input" }); + const built = createInvocationInput(value); + if (!built.bytes.equals(raw)) fail("invocation input is not canonical"); + if (!bindingsMatch(value, authority.value) || value.itemRevision !== authority.value.itemRevision + || built.digest !== authority.value.inputDigest || built.bytes.length !== authority.value.inputByteLength) fail("invocation input does not match dispatch authority"); + return built; +} + +export function validateAgentResult(value, { mode, openFindings = new Map() } = {}) { + if (!exact(value, RESULT_KEYS) || value.schema !== "agent-result@1") fail("invalid agent result"); + identity(value, "agent result"); + if (!OUTCOMES[mode]?.has(value.outcome)) fail("agent outcome is not allowed for node mode"); + const findings = validateFindingSet(value.findings, value.resolvedFindingIds, openFindings); + if (mode === "task" && (findings.findings.length || findings.resolvedFindingIds.length)) fail("task completion cannot carry findings"); + const next = nextOpenFindings(openFindings, findings); + if (mode === "review" && value.outcome === "approve") { + if (findings.findings.some((finding) => finding.severity === "blocker" || finding.severity === "major") + || [...openFindings.values()].some((finding) => (finding.severity === "blocker" || finding.severity === "major") && !findings.resolvedFindingIds.includes(finding.id)) + || [...next.values()].some((finding) => finding.severity === "blocker" || finding.severity === "major")) fail("approval has unresolved blocking findings"); + } + return Object.freeze({ ...value, findings: findings.findings, resolvedFindingIds: findings.resolvedFindingIds }); +} + +/** Parse then validate raw final bytes; bytes stay available for duplicate/retransmission identity. */ +export function parseAgentResult(bytes, options) { return validateAgentResult(parseResultBytes(bytes), options); } diff --git a/src/loops/contracts/check-result.mjs b/src/loops/contracts/check-result.mjs new file mode 100644 index 00000000..81e351cd --- /dev/null +++ b/src/loops/contracts/check-result.mjs @@ -0,0 +1,65 @@ +import { bindingsMatch, DIGESTS, exact, fail, identity, parseResultBytes } from "./contract.mjs"; +import { parseAgentResult } from "./agent-result.mjs"; + +const KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "capabilityRevision", "outcome", "exitCode", "evidenceDigest", "truncated"]; + +export function validateCheckResult(value) { + if (!exact(value, KEYS) || value.schema !== "check-result@1") fail("invalid check result"); + identity(value, "check result"); + if (!DIGESTS.capability.test(value.capabilityRevision) || !["pass", "fail"].includes(value.outcome) + || !Number.isInteger(value.exitCode) || value.exitCode < 0 || value.exitCode > 255 + || !DIGESTS.raw.test(value.evidenceDigest) || typeof value.truncated !== "boolean") fail("invalid check result"); + if (value.outcome === "pass" && (value.exitCode !== 0 || value.truncated)) fail("invalid passing check result"); + if (value.outcome === "fail" && value.exitCode === 0 && !value.truncated) fail("invalid failing check result"); + return Object.freeze({ ...value }); +} + +export function parseCheckResult(bytes) { return validateCheckResult(parseResultBytes(bytes)); } + +/** + * Pure claim-final authority. The durable runner persists dispatch authority and + * invokes this only after quiescence. It never writes a Run or candidate. + */ +export function createClaimFinalAuthority({ expected, type, mode, openFindings = new Map() }) { + if (!expected || !["agent", "check"].includes(type)) fail("invalid claim authority"); + identity(expected, "claim authority"); + let phase = "open"; let finalBytes = null; let final = null; let fault = null; + const decode = (bytes) => type === "agent" ? parseAgentResult(bytes, { mode, openFindings }) : parseCheckResult(bytes); + function accept(raw, { sourceClaimId = expected.claimId, sourceInvocationId = expected.invocationId } = {}) { + const bytes = Buffer.from(raw); + if (phase.startsWith("sealed-")) return Object.freeze({ disposition: "audit-only", reason: "claim-sealed" }); + if (sourceClaimId !== expected.claimId || sourceInvocationId !== expected.invocationId) + return Object.freeze({ disposition: "audit-only", reason: "replaced-or-late-claim" }); + if (phase.startsWith("exited-")) return Object.freeze({ disposition: "audit-only", reason: "process-exited" }); + if (phase === "fault" || phase === "exited-fault") return Object.freeze({ disposition: "audit-only", reason: "claim-faulted" }); + let value; + try { value = decode(bytes); } catch (error) { + fault = error; final = null; finalBytes = null; phase = "fault"; + return Object.freeze({ disposition: "failure-pending-quiescence", reason: "malformed-current-result", error }); + } + if (!bindingsMatch(value, expected)) return Object.freeze({ disposition: "audit-only", reason: "binding-mismatch" }); + if (type === "check" && value.capabilityRevision !== expected.capabilityRevision) return Object.freeze({ disposition: "audit-only", reason: "capability-mismatch" }); + if (!finalBytes) { + finalBytes = Buffer.from(bytes); final = value; phase = "candidate"; + return Object.freeze({ disposition: "buffered", result: value }); + } + if (finalBytes.equals(bytes)) return Object.freeze({ disposition: "idempotent-audit", result: final }); + return Object.freeze({ disposition: "audit-only", reason: "conflicting-duplicate-final" }); + } + function exit() { + if (phase.startsWith("sealed-")) return Object.freeze({ disposition: "audit-only", reason: "claim-sealed" }); + if (phase.startsWith("exited-")) return Object.freeze({ disposition: "idempotent-audit", reason: "exit-already-recorded" }); + phase = phase === "candidate" ? "exited-candidate" : phase === "fault" ? "exited-fault" : "exited-open"; + return Object.freeze({ disposition: "exit-recorded" }); + } + function seal({ quiescent }) { + if (quiescent !== true) fail("claim cannot seal without proven quiescence"); + if (phase === "sealed-success") return Object.freeze({ disposition: "idempotent-audit", reason: "claim-sealed", result: final }); + if (phase === "sealed-error") return Object.freeze({ disposition: "idempotent-audit", reason: "claim-sealed-error" }); + if (!phase.startsWith("exited-")) fail("claim cannot seal before process exit"); + if (phase === "exited-candidate") { phase = "sealed-success"; return Object.freeze({ disposition: "transition", result: final }); } + const reason = phase === "exited-fault" ? "malformed-current-result" : "exit-without-valid-final"; + phase = "sealed-error"; return Object.freeze({ disposition: "error-after-quiescence", reason, error: fault }); + } + return Object.freeze({ accept, exit, seal, get state() { return phase; }, get final() { return phase === "sealed-success" ? final : null; } }); +} diff --git a/src/loops/contracts/contract.mjs b/src/loops/contracts/contract.mjs new file mode 100644 index 00000000..d263f724 --- /dev/null +++ b/src/loops/contracts/contract.mjs @@ -0,0 +1,84 @@ +import { TextDecoder } from "node:util"; +import { RUN_REF } from "../run/run-ref.mjs"; + +export const MAX_RESULT_BYTES = 65_536; +export const MAX_RESULT_DEPTH = 4; +export const DIGESTS = Object.freeze({ + assignment: /^as1-sha256:[a-f0-9]{64}$/u, claim: /^cl1-sha256:[a-f0-9]{64}$/u, + invocation: /^iv1-sha256:[a-f0-9]{64}$/u, recipe: /^er1-sha256:[a-f0-9]{64}$/u, + policy: /^bp1-sha256:[a-f0-9]{64}$/u, candidate: /^cm1-sha256:[a-f0-9]{64}$/u, + capability: /^cp1-sha256:[a-f0-9]{64}$/u, item: /^id1-sha256:[a-f0-9]{64}$/u, + artifact: /^artifact:sha256:[a-f0-9]{64}$/u, raw: /^sha256:[a-f0-9]{64}$/u, +}); +export const RUN = RUN_REF; +export const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +function error(message, code = "ELOOP_RESULT_CONTRACT") { + return Object.assign(new TypeError(`Loop result: ${message}`), { code }); +} +export function fail(message, code) { throw error(message, code); } +export function exact(value, keys) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +export function sortedUnique(values) { + return Array.isArray(values) && values.every((value, index) => typeof value === "string" && (index === 0 || Buffer.compare(Buffer.from(values[index - 1]), Buffer.from(value)) < 0)); +} +export function identity(value, label = "result") { + if (!value || typeof value !== "object" || Array.isArray(value) + || !["runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate"].every((key) => Object.hasOwn(value, key)) + || !RUN.test(value.runId) || !SLUG.test(value.nodeId) || !Number.isInteger(value.attempt) || value.attempt < 1 || value.attempt > 100 + || !DIGESTS.claim.test(value.claimId) || !DIGESTS.assignment.test(value.assignmentId) || !DIGESTS.invocation.test(value.invocationId) + || !DIGESTS.recipe.test(value.recipeRevision) || !DIGESTS.policy.test(value.policyRevision) || !DIGESTS.candidate.test(value.inputCandidate)) fail(`invalid ${label} identity`); + return value; +} +export function bindingsMatch(value, expected) { + return ["runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate"].every((key) => value[key] === expected[key]); +} +/** The runner may journal a post-write candidate only after its child is quiescent. */ +export function postWriteCandidateRecord({ actor, quiescent, candidate, priorCandidate = null }) { + if (actor !== "runner" || quiescent !== true || !DIGESTS.candidate.test(candidate) + || !(priorCandidate === null || DIGESTS.candidate.test(priorCandidate))) fail("post-write candidate lacks runner quiescence authority"); + return Object.freeze({ schema: "burnlist-loop-post-write-candidate@1", actor, quiescent: true, priorCandidate, candidate }); +} +function depth(value, level = 0) { + if (value === null || typeof value !== "object") return level; + return Math.max(level, ...Object.values(value).map((item) => depth(item, level + 1))); +} +function rejectDuplicateKeys(text, maximumDepth) { + let index = 0; + const space = () => { while (/\s/u.test(text[index] ?? "")) index += 1; }; + const string = () => { + const start = index; index += 1; + while (index < text.length) { const char = text[index++]; if (char === "\\") { index += 1; continue; } if (char === '"') return JSON.parse(text.slice(start, index)); } + fail("result has an unterminated string"); + }; + const value = (level) => { + if (level > maximumDepth) fail("result exceeds JSON depth"); + space(); const char = text[index]; + if (char === '"') { string(); return; } + if (char === "{") { + index += 1; const keys = new Set(); space(); if (text[index] === "}") { index += 1; return; } + while (true) { space(); if (text[index] !== '"') fail("result has invalid object key"); const key = string(); if (keys.has(key)) fail("result has duplicate object key"); keys.add(key); space(); if (text[index++] !== ":") fail("result has invalid object separator"); value(level + 1); space(); if (text[index] === "}") { index += 1; return; } if (text[index++] !== ",") fail("result has invalid object separator"); } + } + if (char === "[") { index += 1; space(); if (text[index] === "]") { index += 1; return; } while (true) { value(level + 1); space(); if (text[index] === "]") { index += 1; return; } if (text[index++] !== ",") fail("result has invalid array separator"); } } + const matched = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/u.exec(text.slice(index)); + if (!matched) fail("result has invalid JSON value"); index += matched[0].length; + }; + value(0); space(); if (index !== text.length) fail("result has trailing JSON bytes"); +} +/** Bounds raw bytes before strict UTF-8/JSON work and rejects duplicate object keys. */ +export function parseBoundedObject(bytes, { maximumBytes, maximumDepth, label = "result" }) { + const raw = Buffer.from(bytes); + if (raw.length < 2 || raw.length > maximumBytes) fail(`${label} bytes exceed bounds`); + let text; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(raw); } catch { fail(`${label} is not UTF-8`); } + rejectDuplicateKeys(text, maximumDepth); let value; + try { value = JSON.parse(text); } catch { fail(`${label} is not JSON`); } + if (!value || typeof value !== "object" || Array.isArray(value) || depth(value) > maximumDepth) fail(`${label} exceeds JSON depth`); + return value; +} +/** Strictly parses a bounded UTF-8 JSON result. Canonical JSON is deliberately not required. */ +export function parseResultBytes(bytes) { + return parseBoundedObject(bytes, { maximumBytes: MAX_RESULT_BYTES, maximumDepth: MAX_RESULT_DEPTH }); +} diff --git a/src/loops/contracts/contracts.test.mjs b/src/loops/contracts/contracts.test.mjs new file mode 100644 index 00000000..5988fb3f --- /dev/null +++ b/src/loops/contracts/contracts.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { TextDecoder } from "node:util"; +import test from "node:test"; +import { createDispatchAuthority, createInvocationInput, validateDispatchAuthority, validateInvocationInput, validateAgentResult } from "./agent-result.mjs"; +import { createClaimFinalAuthority, validateCheckResult } from "./check-result.mjs"; +import { findingId, validateFinding } from "./finding.mjs"; +import { postWriteCandidateRecord } from "./contract.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { parseRunRef } from "../assignment/selectors.mjs"; +import { validateRunId } from "../run/run-codec.mjs"; + +const hex = (letter) => letter.repeat(64); +const d = (prefix, letter) => `${prefix}:${hex(letter)}`; +const binding = Object.freeze({ + runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "review", attempt: 2, + claimId: d("cl1-sha256", "1"), assignmentId: d("as1-sha256", "2"), invocationId: d("iv1-sha256", "3"), + recipeRevision: d("er1-sha256", "4"), policyRevision: d("bp1-sha256", "5"), inputCandidate: d("cm1-sha256", "6"), +}); +const artifact = d("artifact:sha256", "a"); +const raw = d("sha256", "b"); +function finding(severity = "minor", summary = "Evidence is insufficient") { + const evidenceRefs = [artifact]; return { id: findingId({ severity, summary, evidenceRefs }), severity, summary, evidenceRefs }; +} +function agent(outcome, extra = {}) { return { schema: "agent-result@1", ...binding, outcome, findings: [], resolvedFindingIds: [], ...extra }; } +function check(outcome, extra = {}) { return { schema: "check-result@1", ...binding, capabilityRevision: d("cp1-sha256", "7"), outcome, exitCode: outcome === "pass" ? 0 : 1, evidenceDigest: raw, truncated: false, ...extra }; } +function bytes(value) { return Buffer.from(JSON.stringify(value)); } +function invocation(extra = {}) { + const instruction = Buffer.from("Follow the frozen instructions.\n"); + return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), instructionDigest: rawSha256(instruction), + instructionBytes: instruction.toString("base64"), candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [], ...extra }; +} +function dispatch(input, extra = {}) { + return createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, + itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length, ...extra }); +} +function fakeAdapterDecode(inputBytes, authorityBytes) { + const decoder = new TextDecoder("utf-8", { fatal: true }); + const authority = JSON.parse(decoder.decode(authorityBytes)); const input = JSON.parse(decoder.decode(inputBytes)); + const digest = `sha256:${createHash("sha256").update(inputBytes).digest("hex")}`; + assert.equal(authority.schema, "burnlist-loop-dispatch-authority@1"); assert.equal(authority.state, "prepared-before-dispatch"); + assert.equal(authority.inputByteLength, inputBytes.length); assert.equal(authority.inputDigest, digest); + for (const key of ["runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision"]) assert.equal(input[key], authority[key]); + return { authority, input, digest }; +} + +test("one canonical 128-bit RunRef grammar governs selectors, results, dispatch, and storage", () => { + const valid = [`run:0${"0".repeat(25)}`, `run:7${"z".repeat(25)}`]; + const invalid = [`run:8${"0".repeat(25)}`, `run:z${"0".repeat(25)}`, `run:0${"0".repeat(24)}`, `run:0${"0".repeat(26)}`]; + for (const runId of valid) { + assert.equal(parseRunRef(runId).selector, runId); assert.equal(validateRunId(runId), runId); + assert.equal(validateAgentResult(agent("complete", { runId }), { mode: "task" }).runId, runId); + assert.equal(validateCheckResult(check("pass", { runId })).runId, runId); + const input = createInvocationInput(invocation({ runId })); assert.equal(input.value.runId, runId); + assert.equal(validateDispatchAuthority(dispatch(input, { runId }).bytes).value.runId, runId); + } + for (const runId of invalid) { + assert.throws(() => parseRunRef(runId), /RunRef/u); assert.throws(() => validateRunId(runId), /RunRef/u); + assert.throws(() => validateAgentResult(agent("complete", { runId }), { mode: "task" }), /identity/u); + assert.throws(() => validateCheckResult(check("pass", { runId })), /identity/u); + assert.throws(() => createInvocationInput(invocation({ runId })), /identity/u); + assert.throws(() => dispatch(createInvocationInput(invocation()), { runId }), /dispatch authority/u); + } +}); + +test("agent outcomes and every finding severity are closed and approval cannot leave blockers", () => { + assert.deepEqual(validateAgentResult(agent("complete"), { mode: "task" }).outcome, "complete"); + for (const outcome of ["approve", "reject", "escalate"]) assert.equal(validateAgentResult(agent(outcome), { mode: "review" }).outcome, outcome); + for (const severity of ["blocker", "major", "minor", "note"]) assert.equal(validateFinding(finding(severity)).severity, severity); + const open = new Map([[finding("blocker").id, finding("blocker")]]); + assert.throws(() => validateAgentResult(agent("approve"), { mode: "review", openFindings: open }), /neither preserved|unresolved/u); + assert.throws(() => validateAgentResult(agent("reject"), { mode: "review", openFindings: open }), /neither preserved/u); + assert.equal(validateAgentResult(agent("reject", { findings: [finding("blocker")] }), { mode: "review", openFindings: open }).outcome, "reject"); + assert.equal(validateAgentResult(agent("approve", { resolvedFindingIds: [finding("blocker").id] }), { mode: "review", openFindings: open }).outcome, "approve"); + assert.throws(() => validateAgentResult(agent("complete", { findings: [finding()] }), { mode: "task" }), /cannot carry/u); + assert.throws(() => validateAgentResult(agent("reject", { findings: [{ ...finding(), summary: "changed" }] }), { mode: "review" }), /id does not bind/u); +}); + +test("finding ids bind distinct accepted UTF-8 and reject surrogate, control, and format code points before hashing", () => { + assert.notEqual(finding("note", "é").id, finding("note", "e\u0301").id); + assert.equal(validateFinding(finding("note", "レビュー")).summary, "レビュー"); + for (const summary of ["bad\u0000text", "bad\u200etext", "bad\ud800text", "bad\u202etext"]) + assert.throws(() => findingId({ severity: "note", summary, evidenceRefs: [artifact] }), /invalid finding/u); +}); + +test("check outcome table accepts only executable pass/fail combinations", () => { + assert.equal(validateCheckResult(check("pass")).outcome, "pass"); + assert.equal(validateCheckResult(check("fail")).outcome, "fail"); + assert.equal(validateCheckResult(check("fail", { exitCode: 0, truncated: true })).outcome, "fail"); + for (const invalid of [check("pass", { exitCode: 1 }), check("pass", { truncated: true }), check("fail", { exitCode: 0, truncated: false })]) assert.throws(() => validateCheckResult(invalid), /invalid/u); +}); + +test("closed bounded contracts reject unknown, missing, oversized, duplicate, and conflicting fields", () => { + assert.throws(() => validateAgentResult({ ...agent("complete"), extra: true }, { mode: "task" }), /invalid/u); + const missing = agent("complete"); delete missing.claimId; assert.throws(() => validateAgentResult(missing, { mode: "task" }), /invalid/u); + assert.throws(() => validateAgentResult(agent("reject", { findings: Array.from({ length: 51 }, () => finding()) }), { mode: "review" }), /invalid/u); + assert.throws(() => validateAgentResult(agent("reject", { findings: [finding("minor", "\u0000")] }), { mode: "review" }), /invalid/u); + assert.throws(() => validateAgentResult(agent("reject", { findings: [finding(), finding()] }), { mode: "review" }), /not id-sorted/u); + assert.throws(() => validateCheckResult({ ...check("pass"), capabilityRevision: "wrong" }), /invalid/u); +}); + +test("claim final state machine buffers, exits, and transitions only at one quiescence seal", () => { + const authority = createClaimFinalAuthority({ expected: binding, type: "agent", mode: "review" }); const accepted = bytes(agent("approve")); + assert.equal(authority.accept(accepted).disposition, "buffered"); assert.equal(authority.state, "candidate"); assert.equal(authority.final, null); + assert.equal(authority.accept(accepted).disposition, "idempotent-audit"); + assert.equal(authority.accept(bytes(agent("reject"))).reason, "conflicting-duplicate-final"); + assert.equal(authority.accept(Buffer.from("not json"), { sourceClaimId: d("cl1-sha256", "9") }).reason, "replaced-or-late-claim"); + assert.throws(() => authority.seal({ quiescent: true }), /before process exit/u); + assert.equal(authority.exit().disposition, "exit-recorded"); assert.equal(authority.state, "exited-candidate"); + assert.equal(authority.accept(accepted).reason, "process-exited"); + assert.throws(() => authority.seal({ quiescent: false }), /quiescence/u); + assert.equal(authority.seal({ quiescent: true }).disposition, "transition"); assert.equal(authority.state, "sealed-success"); + assert.equal(authority.final.outcome, "approve"); + assert.equal(authority.seal({ quiescent: true }).disposition, "idempotent-audit"); + for (const late of [accepted, bytes(agent("reject")), Buffer.from("not json")]) assert.equal(authority.accept(late).reason, "claim-sealed"); +}); + +test("claim final failure table is deterministic for empty, malformed, stale, mismatch, and valid-then-malformed", () => { + for (const malformed of [Buffer.from("not json"), Buffer.from('{"schema":"check-result@1","schema":"check-result@1"}'), Buffer.alloc(65_537, 32)]) { + const current = createClaimFinalAuthority({ expected: { ...binding, capabilityRevision: d("cp1-sha256", "7") }, type: "check" }); + assert.equal(current.accept(malformed).disposition, "failure-pending-quiescence"); + assert.equal(current.accept(bytes(check("pass"))).reason, "claim-faulted"); + current.exit(); assert.equal(current.seal({ quiescent: true }).reason, "malformed-current-result"); + } + const empty = createClaimFinalAuthority({ expected: binding, type: "agent", mode: "review" }); + empty.exit(); assert.equal(empty.seal({ quiescent: true }).reason, "exit-without-valid-final"); + + const mismatch = createClaimFinalAuthority({ expected: { ...binding, capabilityRevision: d("cp1-sha256", "7") }, type: "check" }); + assert.equal(mismatch.accept(bytes(check("pass", { inputCandidate: d("cm1-sha256", "9") }))).reason, "binding-mismatch"); + assert.equal(mismatch.accept(bytes(check("pass", { capabilityRevision: d("cp1-sha256", "9") }))).reason, "capability-mismatch"); + mismatch.exit(); assert.equal(mismatch.seal({ quiescent: true }).reason, "exit-without-valid-final"); + + const ambiguous = createClaimFinalAuthority({ expected: binding, type: "agent", mode: "review" }); + assert.equal(ambiguous.accept(bytes(agent("approve"))).disposition, "buffered"); + assert.equal(ambiguous.accept(Buffer.from("not json")).disposition, "failure-pending-quiescence"); + ambiguous.exit(); assert.equal(ambiguous.seal({ quiescent: true }).reason, "malformed-current-result"); assert.equal(ambiguous.final, null); + + const stale = createClaimFinalAuthority({ expected: binding, type: "agent", mode: "review" }); + assert.equal(stale.accept(Buffer.from("not json"), { sourceClaimId: d("cl1-sha256", "9") }).reason, "replaced-or-late-claim"); + assert.equal(stale.accept(bytes(agent("approve"))).disposition, "buffered"); + stale.exit(); assert.equal(stale.seal({ quiescent: true }).disposition, "transition"); +}); + +test("dispatch artifact and independent fake adapter assert exact invocation bytes and digest", () => { + const built = createInvocationInput(invocation()); + assert.equal(built.bytes.toString("utf8"), `${JSON.stringify(invocation())}\n`); + const authority = dispatch(built); assert.equal(validateDispatchAuthority(authority.bytes).digest, authority.digest); + assert.equal(validateInvocationInput(built.bytes, authority.bytes).digest, built.digest); + assert.equal(fakeAdapterDecode(built.bytes, authority.bytes).digest, built.digest); + assert.throws(() => validateInvocationInput(built.bytes, dispatch(built, { inputDigest: raw }).bytes), /authority/u); + const wrongItem = createInvocationInput(invocation({ itemRevision: d("id1-sha256", "9") })); + assert.throws(() => validateInvocationInput(wrongItem.bytes, authority.bytes), /authority/u); + const stale = createInvocationInput(invocation({ claimId: d("cl1-sha256", "9") })); + assert.throws(() => validateInvocationInput(stale.bytes, authority.bytes), /authority/u); + assert.throws(() => createInvocationInput(invocation({ candidateContext: Buffer.alloc(65537).toString("base64") })), /invalid/u); + assert.throws(() => validateInvocationInput(Buffer.alloc(262_145, 0xff), authority.bytes), /bytes exceed/u); + const duplicate = Buffer.from(built.bytes.toString().replace('{"schema":', '{"schema":"burnlist-loop-invocation-input@1","schema":')); + assert.throws(() => validateInvocationInput(duplicate, authority.bytes), /duplicate/u); + const deep = Buffer.from(built.bytes.toString().replace('"reviewerEvidence":[]', '"reviewerEvidence":[[[[]]]]')); + assert.throws(() => validateInvocationInput(deep, authority.bytes), /depth/u); + assert.throws(() => validateDispatchAuthority(Buffer.from('{"schema":"burnlist-loop-dispatch-authority@1","schema":"burnlist-loop-dispatch-authority@1"}')), /duplicate/u); + assert.throws(() => validateInvocationInput(built.bytes, { arbitrary: true }), /first argument|buffer|Buffer/u); +}); + +test("dispatch authority is closed, bounded, canonical, and prepared before dispatch", () => { + const built = createInvocationInput(invocation()); const valid = dispatch(built); + for (const patch of [{ state: "dispatched" }, { inputByteLength: 0 }, { inputByteLength: 262_145 }, { extra: true }]) + assert.throws(() => createDispatchAuthority({ ...valid.value, ...patch }), /invalid dispatch authority/u); + assert.throws(() => validateDispatchAuthority(Buffer.concat([valid.bytes, Buffer.from(" ")])), /canonical/u); + assert.throws(() => validateDispatchAuthority(Buffer.alloc(16_385, 32)), /bytes exceed/u); +}); + +test("reviewer evidence is bounded, unique, and strictly UTF-8 sorted", () => { + const second = d("artifact:sha256", "b"); + assert.equal(createInvocationInput(invocation({ reviewerEvidence: [artifact, second] })).value.reviewerEvidence.length, 2); + for (const reviewerEvidence of [[artifact, artifact], [second, artifact], Array.from({ length: 51 }, () => artifact)]) + assert.throws(() => createInvocationInput(invocation({ reviewerEvidence })), /invalid invocation evidence/u); +}); + +test("only runner-owned quiescent authority can record a post-write candidate", () => { + assert.equal(postWriteCandidateRecord({ actor: "runner", quiescent: true, candidate: binding.inputCandidate }).candidate, binding.inputCandidate); + assert.throws(() => postWriteCandidateRecord({ actor: "adapter", quiescent: true, candidate: binding.inputCandidate }), /authority/u); + assert.throws(() => postWriteCandidateRecord({ actor: "runner", quiescent: false, candidate: binding.inputCandidate }), /authority/u); +}); diff --git a/src/loops/contracts/finding.mjs b/src/loops/contracts/finding.mjs new file mode 100644 index 00000000..c089c3df --- /dev/null +++ b/src/loops/contracts/finding.mjs @@ -0,0 +1,51 @@ +import { prefixed } from "../dsl/hash.mjs"; +import { DIGESTS, exact, fail, sortedUnique } from "./contract.mjs"; + +const KEYS = ["id", "severity", "summary", "evidenceRefs"]; +const SEVERITIES = new Set(["blocker", "major", "minor", "note"]); +const ID = /^fi1-sha256:[a-f0-9]{64}$/u; +const FORBIDDEN_UNICODE = /[\p{Cc}\p{Cf}\p{Cs}]/u; + +function fields({ severity, summary, evidenceRefs }) { + if (!SEVERITIES.has(severity) || typeof summary !== "string" || !summary || Buffer.byteLength(summary) > 512 + || FORBIDDEN_UNICODE.test(summary) || !Array.isArray(evidenceRefs) || evidenceRefs.length < 1 || evidenceRefs.length > 16 + || !sortedUnique(evidenceRefs) || evidenceRefs.some((ref) => !DIGESTS.artifact.test(ref))) fail("invalid finding"); +} +export function findingId(value) { + fields(value); + const { severity, summary, evidenceRefs } = value; + return prefixed("fi1-sha256:", "finding-v1", [severity, summary, ...evidenceRefs]); +} + +/** Validates a closed finding and proves its content-addressed identity. */ +export function validateFinding(value) { + if (!exact(value, KEYS) || !ID.test(value.id)) fail("invalid finding"); + fields(value); + if (value.id !== findingId(value)) fail("finding id does not bind its content"); + return Object.freeze({ id: value.id, severity: value.severity, summary: value.summary, evidenceRefs: Object.freeze([...value.evidenceRefs]) }); +} + +export function validateFindingSet(findings, resolvedFindingIds, openFindings = new Map()) { + if (!Array.isArray(findings) || findings.length > 50 || !Array.isArray(resolvedFindingIds) || resolvedFindingIds.length > 50 + || !sortedUnique(resolvedFindingIds) || resolvedFindingIds.some((id) => !ID.test(id))) fail("invalid finding set"); + const checked = findings.map(validateFinding); + if (!sortedUnique(checked.map((finding) => finding.id)) || new Set(checked.map((finding) => finding.id)).size !== checked.length) fail("findings are not id-sorted unique"); + const seen = new Set(); + for (const finding of checked) { + const earlier = openFindings.get(finding.id); + if (earlier && JSON.stringify(earlier) !== JSON.stringify(finding)) fail("existing finding id changed"); + seen.add(finding.id); + } + for (const id of resolvedFindingIds) { + if (seen.has(id) || !openFindings.has(id)) fail("resolution is not an open finding"); + } + for (const id of openFindings.keys()) if (!seen.has(id) && !resolvedFindingIds.includes(id)) fail("open finding was neither preserved nor resolved"); + return Object.freeze({ findings: Object.freeze(checked), resolvedFindingIds: Object.freeze([...resolvedFindingIds]) }); +} + +export function nextOpenFindings(openFindings, result) { + const next = new Map(openFindings); + for (const id of result.resolvedFindingIds) next.delete(id); + for (const finding of result.findings) next.set(finding.id, finding); + return next; +} diff --git a/src/loops/dsl/__fixtures__/review.ir.json b/src/loops/dsl/__fixtures__/review.ir.json new file mode 100644 index 00000000..845b08e9 --- /dev/null +++ b/src/loops/dsl/__fixtures__/review.ir.json @@ -0,0 +1 @@ +{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"implement","budget":{"maxRounds":3,"maxMinutes":60,"maxAgentRuns":6,"maxCheckRuns":3,"maxTransitions":16,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"implement","mode":"task","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["verify","review"]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"verify","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"implement","on":"complete","to":"verify","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"review","on":"approve","to":"converged","maxVisits":null},{"from":"review","on":"reject","to":"implement","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"verify","on":"pass","to":"review","maxVisits":null},{"from":"verify","on":"fail","to":"implement","maxVisits":3}],"instructions":[{"id":"implement","digest":"sha256:8d1db5c7cbc11075fccc565180374e1929a8cf22683cbca3dde9b77ef667c0a8","byteLength":114},{"id":"review","digest":"sha256:53ca65be9491688119b5cbf2443d0b9aff003764db9e91657ec54dbae12d76aa","byteLength":119}]} diff --git a/src/loops/dsl/__fixtures__/review.revisions.json b/src/loops/dsl/__fixtures__/review.revisions.json new file mode 100644 index 00000000..75e6d569 --- /dev/null +++ b/src/loops/dsl/__fixtures__/review.revisions.json @@ -0,0 +1 @@ +{"source":"ls1-sha256:a2b31c7cb4f8025516aa51a789a0545d3224b3abc142d18c2ec86291c009c918","package":"lp1-sha256:2f6213c0b5d8e4c6da58d8e8ab227462bf075c672252dc4a30c2736de51d4726","executable":"er1-sha256:8e58f16abc427528e6e10048f80c67f38afcdc7bb5b8f626a1e93b314feba08d"} diff --git a/src/loops/dsl/canonical.mjs b/src/loops/dsl/canonical.mjs new file mode 100644 index 00000000..5281b367 --- /dev/null +++ b/src/loops/dsl/canonical.mjs @@ -0,0 +1,56 @@ +import { compareUtf8 } from "./diagnostics.mjs"; + +const nodeKeyOrder = { agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const topOrder = ["schema", "compiler", "id", "declaredVersion", "entry", "budget", "nodes", "failurePolicy", "edges", "instructions"]; +const budgetOrder = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; +const policyOrder = ["error", "timeout", "cancelled", "lost", "exhausted"]; +const edgeOrder = ["from", "on", "to", "maxVisits"]; + +function quote(value) { + if (typeof value !== "string" || /[\uD800-\uDFFF]/u.test(value)) throw new TypeError("Canonical strings must be scalar Unicode"); + let out = '"'; + for (const char of value) { + const code = char.codePointAt(0); + if (char === '"') out += '\\"'; else if (char === "\\") out += "\\\\"; + else if (char === "\b") out += "\\b"; else if (char === "\f") out += "\\f"; + else if (char === "\n") out += "\\n"; else if (char === "\r") out += "\\r"; + else if (char === "\t") out += "\\t"; + else if (code < 0x20) out += `\\u${code.toString(16).padStart(4, "0")}`; + else out += char; + } + return `${out}"`; +} +function object(value, keys) { return `{${keys.map((key) => `${quote(key)}:${encode(value[key], key)}`).join(",")}}`; } +function encode(value, context = "") { + if (value === null) return "null"; + if (typeof value === "string") return quote(value); + if (typeof value === "number") { if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("Canonical numbers must be safe unsigned integers"); return String(value); } + if (Array.isArray(value)) return `[${value.map((item) => encode(item)).join(",")}]`; + if (!value || typeof value !== "object") throw new TypeError("Canonical IR contains an invalid value"); + if (context === "nodes[]") return object(value, nodeKeyOrder[value.kind]); + if (context === "budget") return object(value, budgetOrder); + if (context === "failurePolicy") return object(value, policyOrder); + if (context === "edges[]") return object(value, edgeOrder); + if (context === "instructions[]") return object(value, ["id", "digest", "byteLength"]); + return object(value, topOrder); +} + +export function canonicalIrBytes(ir) { + const adjusted = { ...ir, nodes: ir.nodes.map((node) => ({ ...node })) }; + const json = `{${topOrder.map((key) => { + const context = key === "nodes" ? "nodes[]" : key === "edges" ? "edges[]" : key === "instructions" ? "instructions[]" : key; + const value = Array.isArray(adjusted[key]) ? `[${adjusted[key].map((item) => encode(item, context)).join(",")}]` : encode(adjusted[key], context); + return `${quote(key)}:${value}`; + }).join(",")}}\n`; + return Buffer.from(json, "utf8"); +} + +export function normalizeIr(ir, outcomeOrder) { + const nodeById = new Map(ir.nodes.map((node) => [node.id, node])); + const nodes = [...ir.nodes].sort((left, right) => left.id === ir.entry ? -1 : right.id === ir.entry ? 1 : compareUtf8(left.id, right.id)); + const nodeRank = new Map(nodes.map((node, index) => [node.id, index])); + const edges = [...ir.edges].sort((left, right) => (nodeRank.get(left.from) ?? Infinity) - (nodeRank.get(right.from) ?? Infinity) || + (outcomeOrder(nodeById.get(left.from))?.indexOf(left.on) ?? 99) - (outcomeOrder(nodeById.get(right.from))?.indexOf(right.on) ?? 99) || compareUtf8(left.to, right.to)); + const instructions = [...ir.instructions].sort((left, right) => compareUtf8(left.id, right.id)); + return { ...ir, nodes, edges, instructions }; +} diff --git a/src/loops/dsl/compile.mjs b/src/loops/dsl/compile.mjs new file mode 100644 index 00000000..ba004ff9 --- /dev/null +++ b/src/loops/dsl/compile.mjs @@ -0,0 +1,132 @@ +import { canonicalIrBytes } from "./canonical.mjs"; +import { createDiagnostics, finalizeDiagnostics } from "./diagnostics.mjs"; +import { prefixed, rawSha256 } from "./hash.mjs"; +import { extractInstructionSections } from "./instructions.mjs"; +import { validateClosedIr } from "./ir-validate.mjs"; +import { validateLoop } from "./grammar.mjs"; +import { parseLoopXml } from "./loop-xml.mjs"; +import { readPackageDirectory } from "./package-read.mjs"; + +const paths = ["review.loop", "instructions.md", "example/item.md"]; +const sizes = { "review.loop": [1, 65536], "instructions.md": [1, 262144], "example/item.md": [0, 65536] }; +const totalLimit = 393216; + +function appendDiagnostics(target, source) { + for (const item of source) { + target.add(item.path, item.byteOffset, item.code, item.message); + } +} + +function sourceChecks(bytes, path, d) { + const [minimum, maximum] = sizes[path]; + if (bytes.length < minimum || bytes.length > maximum) d.add(path, 0, "E_FILE_SIZE", `${path} must contain ${minimum}..${maximum} bytes`); + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (bytes.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))) d.add(path, 0, "E_FILE_BOM", "UTF-8 BOM is not allowed"); + if (text.includes("\0")) d.add(path, 0, "E_FILE_NUL", "NUL is not allowed"); + if (text.includes("\r")) d.add(path, 0, "E_FILE_CR", "Only LF line endings are allowed"); + if (bytes.length && !text.endsWith("\n")) d.add(path, bytes.length, "E_FILE_FINAL_LF", "Nonempty files must end in LF"); + } catch { d.add(path, 0, "E_FILE_UTF8", "File is not valid UTF-8"); } +} + +function collectDiagnostics(...results) { return results.flatMap((result) => result?.allDiagnostics ?? result?.diagnostics ?? []); } + +function packageRevision(data) { + const fields = Object.entries(data).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right))).flatMap(([path, bytes]) => [Buffer.from(path), bytes]); + return prefixed("lp1-sha256:", "package-v1", fields); +} + +function cloneFiles(data) { + return Object.fromEntries(Object.entries(data).map(([path, bytes]) => [path, Buffer.from(bytes)])); +} + +function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { + const d = createDiagnostics(); + if (!files || typeof files !== "object" || Array.isArray(files)) { + d.add("", 0, "E_PACKAGE", "Package files must be an object"); + } + + const actual = Object.keys(files ?? {}); + if (actual.length > 3) d.add("", 0, "E_PACKAGE_COUNT", "Package may contain at most three files"); + for (const path of actual) if (!paths.includes(path)) d.add(path, 0, "E_PACKAGE_PATH", "Unknown package file"); + for (const path of paths.slice(0, 2)) if (!(path in (files ?? {}))) d.add(path, 0, "E_PACKAGE_MISSING", "Required package file is missing"); + + if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all }; + + const data = {}; + for (const path of paths) if (path in (files ?? {})) { + try { + data[path] = Buffer.from(files[path]); + sourceChecks(data[path], path, d); + } catch { + d.add(path, 0, "E_PACKAGE_BYTES", "Package file must be byte data"); + } + } + + if (!("review.loop" in data)) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + if (Object.values(data).reduce((sum, bytes) => sum + bytes.length, 0) > totalLimit) d.add("", 0, "E_PACKAGE_SIZE", "Package exceeds 393216 byte limit"); + if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + + const parsed = parseLoopXml(data["review.loop"]); + const checked = parsed.ast ? validateLoop(parsed.ast) : null; + appendDiagnostics(d, collectDiagnostics(parsed, checked)); + + if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + if (!checked) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + + if (!("instructions.md" in data)) { + return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + } + + const extracted = extractInstructionSections(data["instructions.md"], checked.instructionIds); + appendDiagnostics(d, extracted.diagnostics); + if (extracted.diagnostics.length > 0 || !continueOnMissing && d.all.length) { + return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + } + + const ir = { ...checked.ir, instructions: extracted.sections.map(({ bytes, ...section }) => section) }; + if (!validateClosedIr(ir)) { + d.add("review.loop", 0, "E_IR_INVARIANT", "Closed invariant validation failed"); + return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + } + + const irBytes = canonicalIrBytes(ir); + const revisions = { + source: prefixed("ls1-sha256:", "source-v1", [data["review.loop"]]), + package: packageRevision(data), + executable: prefixed("er1-sha256:", "recipe-v1", [Buffer.from(ir.compiler), irBytes, ...extracted.sections.flatMap((section) => [Buffer.from(section.id), section.bytes])]), + }; + + const packageFiles = Object.fromEntries(Object.entries(data).map(([path, bytes]) => [path, Buffer.from(bytes)])); + return { + ok: true, + ir, + irBytes, + instructions: extracted.sections, + packageFiles, + revisions, + rawSourceDigest: rawSha256(data["review.loop"]), + diagnostics: d.all, + }; +} + +export function compileLoopFiles(files) { + const result = compileLoopFilesInternal(files); + const finalized = finalizeDiagnostics(result.diagnostics); + if (finalized.length) return { ok: false, diagnostics: finalized }; + return { ...result, diagnostics: finalized }; +} + +/** + * Compile package files directly from a directory without trust or execution. + */ +export async function compileLoopPackage(directory, { beforeLeafRead, afterLeafOpenForTest } = {}) { + const { files, diagnostics } = await readPackageDirectory(directory, { + beforeLeafRead, + afterLeafOpenForTest, + }); + const compiled = compileLoopFilesInternal(files, { continueOnMissing: true }); + const merged = finalizeDiagnostics([...diagnostics, ...(compiled.diagnostics ?? [])]); + if (merged.length) return { ok: false, diagnostics: merged }; + return compiled; +} diff --git a/src/loops/dsl/compile.test.mjs b/src/loops/dsl/compile.test.mjs new file mode 100644 index 00000000..45cf88da --- /dev/null +++ b/src/loops/dsl/compile.test.mjs @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compileLoopFiles, compileLoopPackage } from "./compile.mjs"; +import { freezeRecipe, loadFrozenRecipe } from "./frozen.mjs"; +import { renderDiagnostics } from "./diagnostics.mjs"; + +const root = new URL("../../../loops/review/", import.meta.url); +const fixtures = new URL("./__fixtures__/", import.meta.url); +async function reviewFiles() { return { "review.loop": await readFile(new URL("review.loop", root)), "instructions.md": await readFile(new URL("instructions.md", root)) }; } +async function compiled() { const result = compileLoopFiles(await reviewFiles()); assert.equal(result.ok, true, renderDiagnostics(result.diagnostics ?? [])); return result; } + +test("built-in review package compiles to deterministic canonical frozen IR", async () => { + const first = await compiled(), second = await compiled(); + const goldenIr = await readFile(new URL("review.ir.json", fixtures)), goldenRevisions = JSON.parse(await readFile(new URL("review.revisions.json", fixtures))); + assert.deepEqual(first.irBytes, goldenIr); assert.deepEqual(first.revisions, goldenRevisions); + assert.deepEqual(first.irBytes, second.irBytes); assert.deepEqual(first.revisions, second.revisions); + assert.equal(first.ir.schema, "burnlist-loop-ir@1"); + assert.equal(first.ir.compiler, "burnlist-loop-compiler@1"); + assert.deepEqual(first.ir.nodes.map((node) => node.id), ["implement", "completed", "converged", "exhausted", "failed", "needs-human", "review", "stopped", "verify"]); + assert.deepEqual(first.ir.edges.map((edge) => edge.from), ["implement", "converged", "converged", "review", "review", "review", "verify", "verify"]); + assert.match(first.revisions.executable, /^er1-sha256:[a-f0-9]{64}$/); + assert.equal((await compileLoopPackage(new URL("../../../loops/review", import.meta.url).pathname)).ok, true); +}); + +test("runtime consumes persisted frozen IR and validates recipe identity", async () => { + const result = await compiled(), bytes = freezeRecipe(result), frozen = loadFrozenRecipe(bytes); + assert.equal(frozen.irBytes, result.irBytes.toString("base64")); assert.deepEqual(frozen.revisions, result.revisions); + const changed = Buffer.from(bytes).toString().replace("implement", "implement-x"); + assert.throws(() => loadFrozenRecipe(Buffer.from(changed)), /Frozen recipe/); +}); + +test("closed grammar rejects Stage 2 syntax and convergence bypass", async () => { + const files = await reviewFiles(); + for (const replacement of [ + '', '', + '', '', '', + ]) { + const copied = { ...files, "review.loop": Buffer.from(files["review.loop"].toString().replace('', replacement)) }; + const result = compileLoopFiles(copied); assert.equal(result.ok, false, replacement); + assert.ok(result.diagnostics.length > 0); + } +}); + +test("reviewer requirements close on the supervised Stage 1 boundary", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + for (const requirements of [ + "fresh-session:enforced,filesystem-write-deny:enforced", + "fresh-session:enforced,filesystem-write-deny:unsupported", + "fresh-session:enforced,filesystem-write-deny:supervised,container:docker", + ]) { + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace("fresh-session:enforced,filesystem-write-deny:supervised", requirements)) }); + assert.equal(result.ok, false, requirements); + assert.deepEqual(result.diagnostics.map((item) => item.code), ["E_REVIEW_REQUIREMENTS"]); + } +}); + +test("each semantic outcome has one closed, type-safe target", async () => { + const files = await reviewFiles(); + const source = files["review.loop"].toString().replace('from="verify" on="pass" to="review"', 'from="verify" on="pass" to="implement"'); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_TARGET")); +}); + +test("diagnostics are stable, retained, sorted, and capped", async () => { + const files = await reviewFiles(); + const malformed = files["review.loop"].toString().replace('max-rounds="3"', 'max-rounds="0" evil="1"').replace('', ''); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(malformed) }); + assert.equal(result.ok, false); + const lines = renderDiagnostics(result.diagnostics).trim().split("\n"); + assert.ok(lines.some((line) => line.includes("E_ATTRIBUTE_UNKNOWN"))); + assert.ok(lines.some((line) => line.includes("E_SCALAR"))); + assert.ok(lines.every((line) => /^review\.loop:\d+: E_/.test(line))); +}); + +test("package lexical limits fail closed before grammar compilation", async () => { + const files = await reviewFiles(); + const bom = compileLoopFiles({ ...files, "review.loop": Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), files["review.loop"]]) }); + assert.equal(bom.ok, false); assert.ok(bom.diagnostics.some((item) => item.code === "E_FILE_BOM")); + const unknown = compileLoopFiles({ ...files, "later.loop": Buffer.from("x\n") }); + assert.equal(unknown.ok, false); assert.ok(unknown.diagnostics.some((item) => item.code === "E_PACKAGE_PATH")); +}); + +test("instruction extraction is exact and treats fenced headings as prose", async () => { + const files = await reviewFiles(); + const markdown = "## implement\ntext\n```md\n## review\n```\n## review\nreview text\n"; + const good = compileLoopFiles({ ...files, "instructions.md": Buffer.from(markdown) }); + assert.equal(good.ok, true, renderDiagnostics(good.diagnostics ?? [])); + const bad = compileLoopFiles({ ...files, "instructions.md": Buffer.from("## implement\ntext\n## implement\nagain\n") }); + assert.equal(bad.ok, false); assert.ok(bad.diagnostics.some((item) => item.code === "E_INSTRUCTIONS_DUPLICATE")); + const tilde = "## implement\ntext\n~~~ language ` allowed\n## review\n~~~ \n## review\nreview text\n"; + assert.equal(compileLoopFiles({ ...files, "instructions.md": Buffer.from(tilde) }).ok, true); +}); + +test("equivalent source formatting preserves IR and executable identity", async () => { + const files = await reviewFiles(), changed = files["review.loop"].toString().replace("\n { + const result = await compiled(), bytes = freezeRecipe(result), mutate = (fn) => { const value = JSON.parse(bytes); fn(value); return Buffer.from(`${JSON.stringify(value)}\n`); }; + for (const change of [ + (value) => { value.extra = true; }, (value) => { value.ir.extra = true; }, + (value) => { value.ir.nodes[0].extra = true; }, (value) => { value.instructions[0].bytes += "\n"; }, + (value) => { value.revisions.source = value.revisions.source.replace("ls1", "er1"); }, + (value) => { value.revisions.package = value.revisions.package.replace("lp1", "ls1"); }, + (value) => { value.revisions.executable = value.revisions.executable.replace("er1", "lp1"); }, + ]) assert.throws(() => loadFrozenRecipe(mutate(change)), TypeError); + const frozen = loadFrozenRecipe(bytes); + assert.ok(Object.isFrozen(frozen) && Object.isFrozen(frozen.ir) && Object.isFrozen(frozen.ir.nodes[0]) && Object.isFrozen(frozen.instructions[0])); + assert.equal(Buffer.isBuffer(frozen.instructions[0].base64), false); + assert.throws(() => { frozen.ir.nodes[0].id = "changed"; }, TypeError); +}); + +test("frozen replay rejects every closed-IR union, cap, reference, and ordering violation", async () => { + const bytes = freezeRecipe(await compiled()), mutate = (change) => { const value = JSON.parse(bytes); change(value.ir); return Buffer.from(`${JSON.stringify(value)}\n`); }; + for (const change of [ + (ir) => { ir.nodes[0].mode = "stage-two"; }, (ir) => { ir.nodes.find((node) => node.id === "review").requires[1] = "filesystem-write-deny:enforced"; }, (ir) => { ir.edges[0].on = "unknown"; }, + (ir) => { ir.declaredVersion = "01.0.0"; }, (ir) => { ir.budget.maxRounds = 0; }, + (ir) => { ir.nodes.reverse(); }, (ir) => { ir.edges.reverse(); }, (ir) => { ir.instructions.reverse(); }, + (ir) => { ir.nodes[0].instructions = "absent"; }, (ir) => { ir.edges[0].to = "missing"; }, + (ir) => { ir.edges[0].maxVisits = 1; }, (ir) => { ir.failurePolicy.error = "stopped"; }, + ]) assert.throws(() => loadFrozenRecipe(mutate(change)), TypeError); +}); + +test("closed grammar rejects named later constructs and group/order violations", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + for (const name of ["input", "condition", "source", "operator", "target", "map", "foreach", "join", "combine", "branch"]) { + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace('', `<${name} id="later"/>`)) }); + assert.equal(result.ok, false, name); assert.ok(result.diagnostics.some((item) => item.code === "E_ELEMENT_UNKNOWN")); + } + const reordered = source.replace(/(\n) ()/, "$2\n $1"); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(reordered) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_CHILD_GROUP")); +}); + +test("every Stage 1 element required attribute and scalar union is closed", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + const required = [ + 'id="review"', 'version="0.1.0"', 'entry="implement"', 'max-rounds="3"', 'max-minutes="60"', 'max-agent-runs="6"', 'max-check-runs="3"', 'max-transitions="16"', 'max-output-bytes="262144"', + 'id="implement"', 'mode="task"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'capability="repo-verify"', + 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', + 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="implement"', 'on="complete"', 'to="verify"', + ]; + for (const token of required) { + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, "")) }); + assert.equal(result.ok, false, token); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED"), token); + } + for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, replacement)) }); + assert.equal(result.ok, false, replacement); + } +}); + +test("diagnostic truncation and recovery keep the first 99 sorted findings", async () => { + const files = await reviewFiles(), extras = Array.from({ length: 110 }, (_, index) => ` bad-${index}="x"`).join(""); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="3"', `max-rounds="0"${extras}`).replace('', ''); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); + assert.equal(result.diagnostics[0].code, "E_TOO_MANY_DIAGNOSTICS"); + assert.ok(result.diagnostics.some((item) => item.code === "E_SCALAR")); + assert.ok(result.diagnostics.slice(1).every((item, index, all) => index === 0 || item.byteOffset >= all[index - 1].byteOffset)); +}); + +test("grammar diagnoses malformed attribute and complete semantic/system routing", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + const duplicate = source.replace('id="review" version', 'id="review" id="again" version'); + let result = compileLoopFiles({ ...files, "review.loop": Buffer.from(duplicate) }); + assert.equal(result.ok, false); assert.deepEqual(result.diagnostics[0], { path: "review.loop", byteOffset: 18, code: "E_XML_DUPLICATE_ATTRIBUTE", message: "Duplicate attribute id" }); + const missing = source.replace('\n', ""); + result = compileLoopFiles({ ...files, "review.loop": Buffer.from(missing) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_MISSING")); + const system = source.replace('lost="needs-human"', 'lost="failed"'); + result = compileLoopFiles({ ...files, "review.loop": Buffer.from(system) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_FAILURE_POLICY")); + const duplicateSystem = source.replace('error="failed"', 'error="failed" error="failed"'); + result = compileLoopFiles({ ...files, "review.loop": Buffer.from(duplicateSystem) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_XML_DUPLICATE_ATTRIBUTE")); + const bypass = source.replace('from="review" on="approve" to="converged"', 'from="review" on="approve" to="completed"'); + result = compileLoopFiles({ ...files, "review.loop": Buffer.from(bypass) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_TARGET" || item.code === "E_CONVERGENCE_DOMINATION")); +}); + +test("recoverable XML findings merge with semantic findings before global sorting", async () => { + const files = await reviewFiles(); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0" version="still-bad"').replace('max-rounds="3"', 'max-rounds="0" unknown="x"'); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(result.ok, false); + assert.deepEqual(result.diagnostics.map((item) => item.code), ["E_SCALAR", "E_XML_DUPLICATE_ATTRIBUTE", "E_ATTRIBUTE_UNKNOWN", "E_SCALAR"]); + assert.ok(result.diagnostics.every((item, index, all) => index === 0 || item.byteOffset >= all[index - 1].byteOffset)); +}); + +test("literal fenced-section grammar handles both delimiters and false closers", async () => { + const files = await reviewFiles(); + for (const marker of ["`", "~"]) for (const indent of [0, 1, 2, 3]) { + const fence = `${" ".repeat(indent)}${marker.repeat(3)} suffix ${marker}\n## review\n${" ".repeat(indent)}${marker.repeat(4)} \n`; + const markdown = `## implement\ntext\n${fence}## review\nreview text\n`; + const result = compileLoopFiles({ ...files, "instructions.md": Buffer.from(markdown) }); + assert.equal(result.ok, true, `${marker}/${indent}`); + } + const falseCloser = "## implement\ntext\n``` suffix `\n## review\n``` not-close\n## review\nreview\n"; + const result = compileLoopFiles({ ...files, "instructions.md": Buffer.from(falseCloser) }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_INSTRUCTIONS_FENCE")); +}); + +test("frozen recipe replay rejects any noncanonical JSON encoding", async () => { + const result = await compiled(); + const frozen = freezeRecipe(result); + const value = JSON.parse(frozen); + + const reordered = Buffer.from(JSON.stringify({ + compiler: value.compiler, + schema: value.schema, + revisions: value.revisions, + source: value.source, + package: value.package, + ir: value.ir, + instructions: value.instructions, + }) + "\n", "utf8"); + assert.throws(() => loadFrozenRecipe(reordered), /Frozen recipe is not canonical/); + + const spaced = Buffer.from(`${frozen.toString("utf8").trim()} \n`, "utf8"); + assert.throws(() => loadFrozenRecipe(spaced), /Frozen recipe is not canonical/); + + const scientific = Buffer.from(frozen.toString("utf8").replace("\"maxRounds\":3", "\"maxRounds\":1e1"), "utf8"); + assert.throws(() => loadFrozenRecipe(scientific), /Frozen recipe is not canonical/); +}); + +test("compiler and frozen replay are exact under round-trip table cases", async () => { + const files = await reviewFiles(); + const table = [ + { name: "canonical", loop: files["review.loop"].toString() }, + { name: "spacing", loop: files["review.loop"].toString().replace(" { + const files = await reviewFiles(); + const duplicated = compileLoopFiles({ ...files, "review.loop": Buffer.from(files["review.loop"].toString().replace('instructions="review"', 'instructions="implement"')) }); + assert.equal(duplicated.ok, false); + assert.ok(duplicated.diagnostics.some((item) => item.code === "E_IR_INVARIANT")); +}); + +test("compiler invariant mirror rejects non-task maker entry", async () => { + const files = await reviewFiles(); + let source = files["review.loop"].toString().replace('entry="implement"', 'entry="verify"'); + source = source.replace('', ''); + source = source.replace(' ', ' '); + source = source.replace(' ', ' '); + const reassigned = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(reassigned.ok, false); + assert.ok(reassigned.diagnostics.some((item) => item.code === "E_IR_INVARIANT")); +}); + +test("diagnostic truncation keeps the first 99 sorted findings", async () => { + const files = await reviewFiles(), extras = Array.from({ length: 110 }, (_, index) => ` bad-${index}="x"`).join(""); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="3"', `max-rounds="0"${extras}`).replace('', ''); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); + assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); + assert.deepEqual(result.diagnostics[0], { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }); + assert.ok(result.diagnostics.slice(1).every((item, index, all) => index === 0 || item.byteOffset >= all[index - 1].byteOffset)); +}); diff --git a/src/loops/dsl/diagnostics.mjs b/src/loops/dsl/diagnostics.mjs new file mode 100644 index 00000000..6439ca83 --- /dev/null +++ b/src/loops/dsl/diagnostics.mjs @@ -0,0 +1,39 @@ +export const MAX_DIAGNOSTICS = 100; + +export function compareUtf8(left, right) { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +export function sortDiagnostics(items) { + return [...items].sort((left, right) => + compareUtf8(left.path, right.path) || left.byteOffset - right.byteOffset || + compareUtf8(left.code, right.code) || compareUtf8(left.message, right.message)); +} + +export function finalizeDiagnostics(items) { + const sorted = sortDiagnostics(items); + if (sorted.length <= MAX_DIAGNOSTICS) return sorted; + return sortDiagnostics([...sorted.slice(0, MAX_DIAGNOSTICS - 1), { + path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", + message: "Too many diagnostics (maximum 100)", + }]); +} + +/** A closed, bounded Loop diagnostic accumulator. */ +export function createDiagnostics() { + const values = []; + return { + add(path, byteOffset, code, message) { + values.push({ path, byteOffset, code, message }); + }, + get list() { + return finalizeDiagnostics(values); + }, + get all() { return [...values]; }, + }; +} + +export function renderDiagnostics(items) { + return sortDiagnostics(items).map(({ path, byteOffset, code, message }) => + `${path}:${byteOffset}: ${code}: ${message}\n`).join(""); +} diff --git a/src/loops/dsl/frozen.mjs b/src/loops/dsl/frozen.mjs new file mode 100644 index 00000000..8d6ab0aa --- /dev/null +++ b/src/loops/dsl/frozen.mjs @@ -0,0 +1,80 @@ +import { canonicalIrBytes } from "./canonical.mjs"; +import { prefixed, rawSha256 } from "./hash.mjs"; +import { validateClosedIr } from "./ir-validate.mjs"; + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const digest = /^[a-f0-9]{64}$/; +const sections = ["schema", "compiler", "revisions", "source", "package", "ir", "instructions"]; +const revisionKeys = ["source", "package", "executable"]; +const packageSizes = { "review.loop": [1, 65536], "instructions.md": [1, 262144], "example/item.md": [0, 65536] }; + +function sortByPath(entries) { + return [...entries].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); +} + +function canonicalInstruction(item) { + return { id: item.id, digest: item.digest, bytes: item.bytes }; +} + +function hasOrderedKeys(value, keys) { + if (!exact(value, keys)) return false; + return Object.keys(value).length === keys.length && Object.keys(value).every((key, index) => key === keys[index]); +} + +function canonicalFrozenBytes(value) { + if (!exact(value, sections) || !hasOrderedKeys(value, sections) || !exact(value.revisions, revisionKeys) || !hasOrderedKeys(value.revisions, revisionKeys)) return null; + const ir = JSON.parse(canonicalIrBytes(value.ir).toString("utf8")); + const packageFiles = sortByPath(value.package).map((entry) => ({ path: entry.path, bytes: entry.bytes })); + const instructions = [...value.instructions].sort((left, right) => Buffer.compare(Buffer.from(left.id), Buffer.from(right.id)) + ).map((item) => canonicalInstruction(item)); + return Buffer.from(`${JSON.stringify({ + schema: value.schema, + compiler: value.compiler, + revisions: { source: value.revisions.source, package: value.revisions.package, executable: value.revisions.executable }, + source: value.source, + package: packageFiles, + ir, + instructions, + })}\n`, "utf8"); +} + +function exact(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function base64(value) { if (typeof value !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new TypeError("Frozen recipe contains noncanonical base64"); const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new TypeError("Frozen recipe contains noncanonical base64"); return bytes; } +function revision(value, prefix) { return typeof value === "string" && new RegExp(`^${prefix}-sha256:[a-f0-9]{64}$`).test(value); } +function freeze(value) { if (!value || typeof value !== "object") return value; for (const item of Object.values(value)) freeze(item); return Object.freeze(value); } + +/** Serializes the only runtime-facing compiler product: immutable IR and source/package bytes. */ +export function freezeRecipe(compiled) { + if (!compiled?.ok || !compiled.ir || !Buffer.isBuffer(compiled.irBytes) || !compiled.packageFiles) throw new TypeError("A successful compile result is required"); + const packageFiles = Object.entries(compiled.packageFiles).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right))).map(([path, bytes]) => ({ path, bytes: Buffer.from(bytes).toString("base64") })); + const instructions = compiled.instructions.map((section) => ({ id: section.id, digest: section.digest, bytes: Buffer.from(section.bytes).toString("base64") })); + return Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-frozen@1", compiler: compiled.ir.compiler, revisions: compiled.revisions, source: Buffer.from(compiled.packageFiles["review.loop"]).toString("base64"), package: packageFiles, ir: JSON.parse(compiled.irBytes), instructions })}\n`, "utf8"); +} + +/** Runtime/replay boundary: verify frozen bytes and never recompile installed source. */ +export function loadFrozenRecipe(bytes) { + let value; const raw = Buffer.from(bytes); + try { value = JSON.parse(raw.toString("utf8")); } catch { throw new TypeError("Frozen recipe is not valid JSON"); } + try { + const canonical = canonicalFrozenBytes(value); + if (!canonical.equals(raw)) throw new TypeError("Frozen recipe is not canonical"); + } catch { + throw new TypeError("Frozen recipe is not canonical"); + } + if (!exact(value, sections) || value.schema !== "burnlist-loop-frozen@1" || value.compiler !== "burnlist-loop-compiler@1" || !exact(value.revisions, revisionKeys) || !revision(value.revisions.source, "ls1") || !revision(value.revisions.package, "lp1") || !revision(value.revisions.executable, "er1") || !validateClosedIr(value.ir) || value.ir.compiler !== value.compiler) throw new TypeError("Frozen recipe has an invalid envelope"); + const source = base64(value.source); + if (!Array.isArray(value.package) || value.package.length < 2 || value.package.length > 3 || !Array.isArray(value.instructions)) throw new TypeError("Frozen recipe has an invalid package"); + const packageFiles = value.package.map((item) => { if (!exact(item, ["path", "bytes"]) || !Object.hasOwn(packageSizes, item.path)) throw new TypeError("Frozen recipe has an invalid package"); const content = base64(item.bytes), [minimum, maximum] = packageSizes[item.path]; if (content.length < minimum || content.length > maximum) throw new TypeError("Frozen recipe has an invalid package"); return { path: item.path, bytes: content }; }); + if (new Set(packageFiles.map((item) => item.path)).size !== packageFiles.length || packageFiles.reduce((total, item) => total + item.bytes.length, 0) > 393216 || !packageFiles.some((item) => item.path === "review.loop" && item.bytes.equals(source)) || !packageFiles.some((item) => item.path === "instructions.md")) throw new TypeError("Frozen recipe has an invalid package"); + const sortedPackage = [...packageFiles].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); + const packageDigest = prefixed("lp1-sha256:", "package-v1", sortedPackage.flatMap((item) => [Buffer.from(item.path), item.bytes])); + if (prefixed("ls1-sha256:", "source-v1", [source]) !== value.revisions.source || packageDigest !== value.revisions.package) throw new TypeError("Frozen recipe provenance revision does not match bytes"); + const parsedInstructions = value.instructions.map((item) => { if (!exact(item, ["id", "digest", "bytes"]) || !slug.test(item.id) || !/^sha256:[a-f0-9]{64}$/.test(item.digest)) throw new TypeError("Frozen recipe instruction is invalid"); const content = base64(item.bytes); if (rawSha256(content) !== item.digest) throw new TypeError("Frozen recipe instruction is invalid"); return { id: item.id, digest: item.digest, byteLength: content.length, base64: item.bytes, content }; }); + if (new Set(parsedInstructions.map((item) => item.id)).size !== parsedInstructions.length) throw new TypeError("Frozen recipe instruction is invalid"); + const irBytes = canonicalIrBytes(value.ir); + if (JSON.stringify(value.ir.instructions) !== JSON.stringify(parsedInstructions.map(({ content, base64: _base64, ...item }) => item))) throw new TypeError("Frozen recipe instructions do not match IR"); + const executable = prefixed("er1-sha256:", "recipe-v1", [Buffer.from(value.ir.compiler), irBytes, ...parsedInstructions.flatMap((item) => [Buffer.from(item.id), item.content])]); + if (executable !== value.revisions.executable) throw new TypeError("Frozen recipe executable revision does not match bytes"); + const instructions = parsedInstructions.map(({ content: _content, ...item }) => freeze(item)); + return freeze({ ir: value.ir, irBytes: irBytes.toString("base64"), instructions, revisions: { ...value.revisions } }); +} diff --git a/src/loops/dsl/grammar.mjs b/src/loops/dsl/grammar.mjs new file mode 100644 index 00000000..1fea377e --- /dev/null +++ b/src/loops/dsl/grammar.mjs @@ -0,0 +1,114 @@ +import { createDiagnostics } from "./diagnostics.mjs"; +import { normalizeIr } from "./canonical.mjs"; + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const route = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/; +const semver = /^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/; +const positive = /^[1-9][0-9]*$/; +const order = ["budget", "agent", "check", "agent", "gate", "terminal", "terminal", "terminal", "terminal", "terminal", "failure-policy", "edge", "edge", "edge", "edge", "edge", "edge", "edge", "edge"]; +const attrs = { + loop: ["id", "version", "entry"], budget: ["max-rounds", "max-minutes", "max-agent-runs", "max-check-runs", "max-transitions", "max-output-bytes"], + agent: ["id", "mode", "role", "route", "authority", "instructions", "independent-from", "requires"], check: ["id", "capability"], gate: ["id", "kind", "requires"], + terminal: ["id", "state"], "failure-policy": ["error", "timeout", "cancelled", "lost", "exhausted"], edge: ["from", "on", "to", "max-visits"], +}; +const required = { + loop: attrs.loop, budget: attrs.budget, agent: ["id", "mode", "role", "route", "authority", "instructions"], check: ["id", "capability"], gate: attrs.gate, terminal: attrs.terminal, + "failure-policy": attrs["failure-policy"], edge: ["from", "on", "to"], +}; +const outcomes = { agent: (node) => node.mode === "task" ? ["complete"] : ["approve", "reject", "escalate"], check: () => ["pass", "fail"], gate: () => ["pass", "fail"], terminal: () => [] }; +const terminalStates = ["converged", "needs-human", "failed", "stopped", "budget-exhausted"]; +const limits = { "max-rounds": [1, 100], "max-minutes": [1, 1440], "max-agent-runs": [1, 100], "max-check-runs": [1, 100], "max-transitions": [1, 1000], "max-output-bytes": [1024, 1048576] }; + +function add(d, node, code, message) { d.add("review.loop", node?.byteOffset ?? 0, code, message); } +function exactAttrs(d, node) { + const allowed = attrs[node.name]; + if (!allowed) { add(d, node, "E_ELEMENT_UNKNOWN", `Unknown Stage 1 element <${node.name}>`); return; } + for (const key of Object.keys(node.attrs)) if (!allowed.includes(key)) add(d, node, "E_ATTRIBUTE_UNKNOWN", `Attribute ${key} is not allowed on <${node.name}>`); + for (const key of required[node.name]) if (!(key in node.attrs)) add(d, node, "E_ATTRIBUTE_REQUIRED", `<${node.name}> requires attribute ${key}`); +} +function value(d, node, key, test, description) { if (key in node.attrs && !test(node.attrs[key])) add(d, node, "E_SCALAR", `${key} must be ${description}`); } +function reachesTerminal(nodes, edges, start) { + const byFrom = new Map(); for (const edge of edges) (byFrom.get(edge.from) ?? byFrom.set(edge.from, []).get(edge.from)).push(edge.to); + const seen = new Set(), work = [start]; while (work.length) { const id = work.pop(); if (seen.has(id)) continue; seen.add(id); for (const next of byFrom.get(id) ?? []) work.push(next); } + return seen; +} +function backEdges(nodes, edges, entry) { + const byFrom = new Map(); for (const edge of edges) (byFrom.get(edge.from) ?? byFrom.set(edge.from, []).get(edge.from)).push(edge); + const color = new Map(), result = new Set(); + const visit = (id) => { color.set(id, 1); for (const edge of byFrom.get(id) ?? []) { if (color.get(edge.to) === 1) result.add(edge); else if (!color.get(edge.to)) visit(edge.to); } color.set(id, 2); }; + visit(entry); return result; +} + +/** Validates the full closed Stage 1 grammar and emits normalized symbolic IR. */ +export function validateLoop(ast) { + const d = createDiagnostics(); + if (!ast || ast.name !== "loop") { add(d, ast, "E_ROOT", "Root element must be "); return { diagnostics: d.list, allDiagnostics: d.all }; } + exactAttrs(d, ast); + if (ast.selfClosing) add(d, ast, "E_ROOT_FORM", "Root must not be self-closing"); + value(d, ast, "id", (v) => slug.test(v), "a lowercase slug"); value(d, ast, "version", (v) => semver.test(v), "a Stage 1 SemVer"); value(d, ast, "entry", (v) => slug.test(v), "a lowercase slug"); + ast.children.forEach((node, index) => { exactAttrs(d, node); if (!node.selfClosing) add(d, node, "E_CHILD_FORM", `<${node.name}> must be self-closing`); if (node.children.length) add(d, node, "E_CHILDREN", `<${node.name}> may not have children`); if (node.name !== order[index]) add(d, node, "E_CHILD_ORDER", "Children must use the closed Stage 1 group order"); if (index === 1 && node.attrs.mode !== "task") add(d, node, "E_CHILD_GROUP", "Maker agent group must precede check"); if (index === 3 && node.attrs.mode !== "review") add(d, node, "E_CHILD_GROUP", "Reviewer agent group must follow check"); }); + if (ast.children.length !== order.length) add(d, ast, "E_CHILD_COUNT", "Loop must contain the exact closed Stage 1 child set"); + const ids = new Map(), nodes = [], edges = [], policy = ast.children.find((node) => node.name === "failure-policy"); + for (const node of ast.children) { + if (["agent", "check", "gate", "terminal"].includes(node.name)) { + value(d, node, "id", (v) => slug.test(v), "a lowercase slug"); + if (node.attrs.id && ids.has(node.attrs.id)) add(d, node, "E_ID_DUPLICATE", `Duplicate id ${node.attrs.id}`); else if (node.attrs.id) ids.set(node.attrs.id, node); + } + if (node.name === "budget") for (const [key, [min, max]] of Object.entries(limits)) value(d, node, key, (v) => positive.test(v) && +v >= min && +v <= max, `an integer from ${min} through ${max}`); + if (node.name === "agent") { + value(d, node, "mode", (v) => v === "task" || v === "review", "task or review"); value(d, node, "role", (v) => v === "maker" || v === "reviewer", "maker or reviewer"); + value(d, node, "route", (v) => route.test(v), "a Route"); value(d, node, "authority", (v) => v === "read" || v === "write", "read or write"); value(d, node, "instructions", (v) => slug.test(v), "a lowercase slug"); + const task = node.attrs.mode === "task"; const expected = task ? ["maker", "write"] : ["reviewer", "read"]; + if ((node.attrs.role && node.attrs.authority) && (node.attrs.role !== expected[0] || node.attrs.authority !== expected[1])) add(d, node, "E_AGENT_COMBINATION", `${node.attrs.mode} agent must be ${expected[0]}/${expected[1]}`); + if (task && ("independent-from" in node.attrs || "requires" in node.attrs)) add(d, node, "E_AGENT_ATTRIBUTES", "Task agent may not have review-only attributes"); + if (!task) { for (const key of ["independent-from", "requires"]) if (!(key in node.attrs)) add(d, node, "E_ATTRIBUTE_REQUIRED", ` requires attribute ${key}`); if (node.attrs.requires !== "fresh-session:enforced,filesystem-write-deny:supervised") add(d, node, "E_REVIEW_REQUIREMENTS", "Review requires must be fresh-session:enforced,filesystem-write-deny:supervised"); } + nodes.push({ kind: "agent", id: node.attrs.id, mode: node.attrs.mode, role: node.attrs.role, route: node.attrs.route, authority: node.attrs.authority, instructions: node.attrs.instructions, independentFrom: node.attrs["independent-from"] ?? null, requires: node.attrs.requires ? node.attrs.requires.split(",") : [] }); + } + if (node.name === "check") { value(d, node, "capability", (v) => slug.test(v), "a lowercase slug"); nodes.push({ kind: "check", id: node.attrs.id, capability: node.attrs.capability }); } + if (node.name === "gate") { if (node.attrs.kind !== "convergence") add(d, node, "E_GATE_KIND", "Stage 1 gate kind must be convergence"); nodes.push({ kind: "gate", id: node.attrs.id, gateKind: node.attrs.kind, requires: node.attrs.requires?.split(",") ?? [] }); } + if (node.name === "terminal") { if (!terminalStates.includes(node.attrs.state)) add(d, node, "E_TERMINAL_STATE", "Terminal state is not allowed in Stage 1"); nodes.push({ kind: "terminal", id: node.attrs.id, state: node.attrs.state }); } + if (node.name === "edge") { if ("max-visits" in node.attrs) value(d, node, "max-visits", (v) => positive.test(v) && +v <= 100, "an integer from 1 through 100"); edges.push({ from: node.attrs.from, on: node.attrs.on, to: node.attrs.to, maxVisits: node.attrs["max-visits"] ? +node.attrs["max-visits"] : null, offset: node.byteOffset }); } + } + const agents = nodes.filter((node) => node.kind === "agent"), checks = nodes.filter((node) => node.kind === "check"), gates = nodes.filter((node) => node.kind === "gate"), terminals = nodes.filter((node) => node.kind === "terminal"); + if (agents.length !== 2 || agents.filter((node) => node.mode === "task").length !== 1 || agents.filter((node) => node.mode === "review").length !== 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires one task agent and one review agent"); + if (checks.length !== 1 || gates.length !== 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires exactly one check and one convergence gate"); + for (const state of terminalStates) if (terminals.filter((node) => node.state === state).length !== 1) add(d, ast, "E_TERMINAL_CARDINALITY", `Stage 1 requires exactly one ${state} terminal`); + const reviewer = agents.find((node) => node.mode === "review"), maker = agents.find((node) => node.mode === "task"), check = checks[0], gate = gates[0]; + if (reviewer && reviewer.independentFrom !== maker?.id) add(d, ids.get(reviewer.id), "E_REVIEW_INDEPENDENCE", "Reviewer independent-from must name the task agent"); + if (gate && `${gate.requires.join(",")}` !== `${check?.id ?? ""},${reviewer?.id ?? ""}`) add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must name check then reviewer"); + if (policy) for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) { const target = ids.get(policy.attrs[outcome]); if (!target || target.attrs.state !== state) add(d, policy, "E_FAILURE_POLICY", `${outcome} must target the ${state} terminal`); } + for (const edge of edges) { + const from = ids.get(edge.from), to = ids.get(edge.to); + if (!from || !to) { d.add("review.loop", edge.offset, "E_EDGE_REFERENCE", "Edge endpoints must name declared nodes"); continue; } + if (from.name === "terminal") d.add("review.loop", edge.offset, "E_EDGE_TERMINAL", "Terminal nodes may not have edges"); + const source = nodes.find((node) => node.id === edge.from); + if (!outcomes[source?.kind]?.(source).includes(edge.on)) d.add("review.loop", edge.offset, "E_EDGE_OUTCOME", `Outcome ${edge.on} is not emitted by ${edge.from}`); + const target = nodes.find((node) => node.id === edge.to); + const allowed = (source?.kind === "agent" && source.mode === "task" && edge.on === "complete" && target?.kind === "check") || + (source?.kind === "check" && edge.on === "pass" && target?.kind === "agent" && target.mode === "review") || + (source?.kind === "check" && edge.on === "fail" && target?.kind === "agent" && target.mode === "task") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "reject" && target?.kind === "agent" && target.mode === "task") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "approve" && target?.kind === "gate") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "escalate" && target?.kind === "terminal" && target.state === "needs-human") || + (source?.kind === "gate" && edge.on === "pass" && target?.kind === "terminal" && target.state === "converged") || + (source?.kind === "gate" && edge.on === "fail" && target?.kind === "terminal" && target.state === "needs-human"); + if (!allowed) d.add("review.loop", edge.offset, "E_EDGE_TARGET", `Target ${edge.to} is not allowed for ${edge.from}/${edge.on}`); + } + const pairs = new Set(); for (const edge of edges) { const key = `${edge.from}\0${edge.on}`; if (pairs.has(key)) d.add("review.loop", edge.offset, "E_EDGE_DUPLICATE", `Duplicate edge outcome ${edge.from}/${edge.on}`); pairs.add(key); } + for (const node of nodes.filter((node) => node.kind !== "terminal")) for (const outcome of outcomes[node.kind](node)) if (!pairs.has(`${node.id}\0${outcome}`)) add(d, ids.get(node.id), "E_EDGE_MISSING", `Missing edge for ${node.id}/${outcome}`); + const converged = terminals.find((node) => node.state === "converged"); + if (converged) for (const edge of edges.filter((edge) => edge.to === converged.id)) if (edge.from !== gate?.id || edge.on !== "pass") d.add("review.loop", edge.offset, "E_CONVERGENCE_DOMINATION", "Only convergence gate pass may target the converged terminal"); + if (converged && !edges.some((edge) => edge.from === gate?.id && edge.on === "pass" && edge.to === converged.id)) add(d, ids.get(gate?.id), "E_CONVERGENCE_DOMINATION", "Convergence gate pass must target the converged terminal"); + // System outcomes are runner-owned but are still graph routes for reachability. + const systemEdges = policy ? nodes.filter((node) => node.kind !== "terminal").flatMap((node) => Object.entries(policy.attrs).map(([on, to]) => ({ from: node.id, on, to }))) : []; + const reachable = reachesTerminal(nodes, [...edges, ...systemEdges], ast.attrs.entry); if (!ids.has(ast.attrs.entry)) add(d, ast, "E_ENTRY", "Entry must name a declared node"); + else for (const node of nodes) if (!reachable.has(node.id)) add(d, ids.get(node.id), "E_REACHABILITY", `Node ${node.id} is not reachable from entry`); + const back = ids.has(ast.attrs.entry) ? backEdges(nodes, edges, ast.attrs.entry) : new Set(); + for (const edge of edges) { const needs = back.has(edge); if (needs !== (edge.maxVisits !== null)) d.add("review.loop", edge.offset, "E_EDGE_VISITS", needs ? "DFS back edges require max-visits" : "max-visits is allowed only on DFS back edges"); } + if (d.all.length) return { diagnostics: d.list, allDiagnostics: d.all }; + const ir = normalizeIr({ schema: "burnlist-loop-ir@1", compiler: "burnlist-loop-compiler@1", id: ast.attrs.id, declaredVersion: ast.attrs.version, entry: ast.attrs.entry, + budget: { maxRounds: +ast.children[0].attrs["max-rounds"], maxMinutes: +ast.children[0].attrs["max-minutes"], maxAgentRuns: +ast.children[0].attrs["max-agent-runs"], maxCheckRuns: +ast.children[0].attrs["max-check-runs"], maxTransitions: +ast.children[0].attrs["max-transitions"], maxOutputBytes: +ast.children[0].attrs["max-output-bytes"] }, nodes, failurePolicy: { error: policy.attrs.error, timeout: policy.attrs.timeout, cancelled: policy.attrs.cancelled, lost: policy.attrs.lost, exhausted: policy.attrs.exhausted }, edges: edges.map(({ offset, ...edge }) => edge), instructions: [] }, (node) => outcomes[node.kind](node)); + return { ir, instructionIds: agents.map((node) => node.instructions), diagnostics: [], allDiagnostics: [] }; +} + +export function outcomesFor(node) { return outcomes[node.kind](node); } diff --git a/src/loops/dsl/hash.mjs b/src/loops/dsl/hash.mjs new file mode 100644 index 00000000..ff4fcbac --- /dev/null +++ b/src/loops/dsl/hash.mjs @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; + +const encoder = new TextEncoder(); +const MAX_U32 = 0xffffffff; +const MAX_U64 = (1n << 64n) - 1n; + +export function utf8(value) { return encoder.encode(String(value)); } + +function length32(length) { + if (!Number.isSafeInteger(length) || length < 0 || length > MAX_U32) throw new RangeError("u32 length overflow"); + const out = Buffer.allocUnsafe(4); out.writeUInt32BE(length); return out; +} +function length64(length) { + if (!Number.isSafeInteger(length) || length < 0 || BigInt(length) > MAX_U64) throw new RangeError("u64 length overflow"); + const out = Buffer.allocUnsafe(8); out.writeBigUInt64BE(BigInt(length)); return out; +} +function bytes(value) { return Buffer.isBuffer(value) ? value : Buffer.from(value); } + +/** Stage 1's domain-separated digest framing. */ +export function hashFields(domain, fields) { + const digest = createHash("sha256"); + const domainBytes = Buffer.from(utf8(domain)); + digest.update("burnlist-hash-v1\0", "utf8"); + digest.update(length32(domainBytes.length)); + digest.update(domainBytes); + digest.update(length32(fields.length)); + for (const field of fields) { + const value = bytes(field); + digest.update(length64(value.length)); + digest.update(value); + } + return digest.digest("hex"); +} + +export function prefixed(prefix, domain, fields) { + return `${prefix}${hashFields(domain, fields)}`; +} + +export function rawSha256(value) { return `sha256:${createHash("sha256").update(bytes(value)).digest("hex")}`; } diff --git a/src/loops/dsl/instructions.mjs b/src/loops/dsl/instructions.mjs new file mode 100644 index 00000000..80c3e9ce --- /dev/null +++ b/src/loops/dsl/instructions.mjs @@ -0,0 +1,51 @@ +import { createDiagnostics } from "./diagnostics.mjs"; +import { rawSha256 } from "./hash.mjs"; + +const heading = /^## ([a-z0-9]+(?:-[a-z0-9]+)*)\n$/; +function fenceOpener(line) { + const found = line.match(/^ {0,3}(`{3,}|~{3,})[^\n]*\n$/); + return found ? { char: found[1][0], length: found[1].length } : null; +} + +function decode(bytes, path, d) { + try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } + catch { d.add(path, 0, "E_INSTRUCTIONS_UTF8", "Instructions are not valid UTF-8"); return null; } +} + +/** Extract only the exact executable instruction sections selected by Loop agents. */ +export function extractInstructionSections(input, ids, { path = "instructions.md" } = {}) { + const d = createDiagnostics(); + const bytes = Buffer.from(input); + const source = decode(bytes, path, d); + if (source === null) return { diagnostics: d.list }; + if (source.includes("\r")) d.add(path, 0, "E_INSTRUCTIONS_CRLF", "Instructions must use LF line endings"); + if (!source.endsWith("\n")) d.add(path, bytes.length, "E_INSTRUCTIONS_FINAL_LF", "Instructions must end in LF"); + const sections = new Map(); let activeFence = null, current = null, currentStart = 0, position = 0; + for (const line of source.matchAll(/[^\n]*\n|[^\n]+$/g)) { + const value = line[0], lineOffset = position; position += Buffer.byteLength(value); + if (activeFence) { + const close = new RegExp(`^ {0,3}${activeFence.char}{${activeFence.length},} *\\n$`); + if (close.test(value)) activeFence = null; + continue; + } + const opener = fenceOpener(value); + if (opener) { activeFence = opener; continue; } + const found = value.match(heading); + if (!found) continue; + if (current) sections.set(current.id, { ...current, bytes: bytes.subarray(currentStart, lineOffset) }); + current = { id: found[1], headingOffset: lineOffset }; currentStart = position; + if (sections.has(current.id)) d.add(path, lineOffset, "E_INSTRUCTIONS_DUPLICATE", `Duplicate instruction section ${current.id}`); + } + if (activeFence) d.add(path, bytes.length, "E_INSTRUCTIONS_FENCE", "Unclosed fenced block"); + if (current) sections.set(current.id, { ...current, bytes: bytes.subarray(currentStart) }); + const selected = []; + for (const id of [...new Set(ids)].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) { + const section = sections.get(id); + if (!section) { d.add(path, 0, "E_INSTRUCTIONS_MISSING", `Missing instruction section ${id}`); continue; } + if (section.bytes.length < 1 || section.bytes.length > 65536 || !/\S/u.test(new TextDecoder().decode(section.bytes))) { + d.add(path, section.headingOffset, "E_INSTRUCTIONS_CONTENT", `Instruction section ${id} must contain 1..65536 non-whitespace bytes`); continue; + } + selected.push({ id, digest: rawSha256(section.bytes), byteLength: section.bytes.length, bytes: Buffer.from(section.bytes) }); + } + return { sections: selected, diagnostics: d.list }; +} diff --git a/src/loops/dsl/ir-validate.mjs b/src/loops/dsl/ir-validate.mjs new file mode 100644 index 00000000..6bd01d4c --- /dev/null +++ b/src/loops/dsl/ir-validate.mjs @@ -0,0 +1,58 @@ +import { normalizeIr } from "./canonical.mjs"; +import { outcomesFor } from "./grammar.mjs"; + +const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const route = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/; +const semver = /^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/; +const states = ["converged", "needs-human", "failed", "stopped", "budget-exhausted"]; +const top = ["schema", "compiler", "id", "declaredVersion", "entry", "budget", "nodes", "failurePolicy", "edges", "instructions"]; +const budget = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; +const policy = ["error", "timeout", "cancelled", "lost", "exhausted"]; +const nodeKeys = { agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const limits = { maxRounds: [1, 100], maxMinutes: [1, 1440], maxAgentRuns: [1, 100], maxCheckRuns: [1, 100], maxTransitions: [1, 1000], maxOutputBytes: [1024, 1048576] }; + +function exact(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function boundedSlug(value) { return typeof value === "string" && value.length <= 65536 && slug.test(value); } +function integer(value, range = [0, Number.MAX_SAFE_INTEGER]) { return Number.isSafeInteger(value) && value >= range[0] && value <= range[1]; } +function same(left, right) { return JSON.stringify(left) === JSON.stringify(right); } +function validNode(node) { + if (!exact(node, nodeKeys[node?.kind] ?? []) || !boundedSlug(node.id)) return false; + if (node.kind === "agent") return ["task", "review"].includes(node.mode) && ["maker", "reviewer"].includes(node.role) && route.test(node.route) && ["read", "write"].includes(node.authority) && boundedSlug(node.instructions) && (node.independentFrom === null || boundedSlug(node.independentFrom)) && Array.isArray(node.requires) && node.requires.every((item) => typeof item === "string" && item.length <= 128); + if (node.kind === "check") return boundedSlug(node.capability); + if (node.kind === "gate") return node.gateKind === "convergence" && Array.isArray(node.requires) && node.requires.every(boundedSlug); + return states.includes(node.state); +} +function targetAllowed(source, outcome, target) { + return (source.kind === "agent" && source.mode === "task" && outcome === "complete" && target.kind === "check") || + (source.kind === "check" && outcome === "pass" && target.kind === "agent" && target.mode === "review") || + (source.kind === "check" && outcome === "fail" && target.kind === "agent" && target.mode === "task") || + (source.kind === "agent" && source.mode === "review" && outcome === "reject" && target.kind === "agent" && target.mode === "task") || + (source.kind === "agent" && source.mode === "review" && outcome === "approve" && target.kind === "gate") || + (source.kind === "agent" && source.mode === "review" && outcome === "escalate" && target.kind === "terminal" && target.state === "needs-human") || + (source.kind === "gate" && outcome === "pass" && target.kind === "terminal" && target.state === "converged") || + (source.kind === "gate" && outcome === "fail" && target.kind === "terminal" && target.state === "needs-human"); +} + +/** Rejects every noncanonical or unsupported symbolic IR before frozen replay. */ +export function validateClosedIr(ir) { + if (!exact(ir, top) || ir.schema !== "burnlist-loop-ir@1" || ir.compiler !== "burnlist-loop-compiler@1" || !boundedSlug(ir.id) || !semver.test(ir.declaredVersion) || !boundedSlug(ir.entry) || !exact(ir.budget, budget) || !Object.entries(limits).every(([key, range]) => integer(ir.budget[key], range)) || !exact(ir.failurePolicy, policy) || !Object.values(ir.failurePolicy).every(boundedSlug) || !Array.isArray(ir.nodes) || ir.nodes.length > 64 || !ir.nodes.every(validNode) || !Array.isArray(ir.edges) || ir.edges.length > 512 || !Array.isArray(ir.instructions) || ir.instructions.length > 2) return false; + const ids = new Map(ir.nodes.map((node) => [node.id, node])); + if (ids.size !== ir.nodes.length || !ids.has(ir.entry)) return false; + const agents = ir.nodes.filter((node) => node.kind === "agent"), makers = agents.filter((node) => node.mode === "task" && node.role === "maker" && node.authority === "write"), reviewers = agents.filter((node) => node.mode === "review" && node.role === "reviewer" && node.authority === "read"), checks = ir.nodes.filter((node) => node.kind === "check"), gates = ir.nodes.filter((node) => node.kind === "gate"), terminals = ir.nodes.filter((node) => node.kind === "terminal"), agentInstructionIds = new Set(agents.map((agent) => agent.instructions)); + if (agents.length !== 2 || makers.length !== 1 || reviewers.length !== 1 || checks.length !== 1 || gates.length !== 1 || ir.entry !== makers[0].id || states.some((state) => terminals.filter((node) => node.state === state).length !== 1) || reviewers[0].independentFrom !== makers[0].id || !same(reviewers[0].requires, ["fresh-session:enforced", "filesystem-write-deny:supervised"]) || makers[0].independentFrom !== null || makers[0].requires.length || !same(gates[0].requires, [checks[0].id, reviewers[0].id])) return false; + if (agentInstructionIds.size !== 2) return false; + if (!ir.instructions.every((section) => exact(section, ["id", "digest", "byteLength"]) && boundedSlug(section.id) && /^sha256:[a-f0-9]{64}$/.test(section.digest) && integer(section.byteLength, [1, 65536])) || new Set(ir.instructions.map((section) => section.id)).size !== ir.instructions.length || !same(ir.instructions.map((section) => section.id).sort(), [makers[0].instructions, reviewers[0].instructions].sort())) return false; + for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) if (ids.get(ir.failurePolicy[outcome])?.state !== state) return false; + const pairs = new Set(); + for (const edge of ir.edges) { + if (!exact(edge, ["from", "on", "to", "maxVisits"]) || !boundedSlug(edge.from) || !boundedSlug(edge.to) || typeof edge.on !== "string" || edge.on.length > 64 || (edge.maxVisits !== null && !integer(edge.maxVisits, [1, 100]))) return false; + const source = ids.get(edge.from), target = ids.get(edge.to), key = `${edge.from}\0${edge.on}`; + if (!source || !target || pairs.has(key) || !outcomesFor(source).includes(edge.on) || !targetAllowed(source, edge.on, target)) return false; + const back = (source.kind === "check" && edge.on === "fail") || (source.kind === "agent" && source.mode === "review" && edge.on === "reject"); + if ((edge.maxVisits !== null) !== back) return false; + pairs.add(key); + } + if (ir.edges.length !== 8 || ir.nodes.filter((node) => node.kind !== "terminal").some((node) => outcomesFor(node).some((outcome) => !pairs.has(`${node.id}\0${outcome}`)))) return false; + const normalized = normalizeIr(ir, outcomesFor); + return same(ir.nodes, normalized.nodes) && same(ir.edges, normalized.edges) && same(ir.instructions, normalized.instructions); +} diff --git a/src/loops/dsl/loop-xml.mjs b/src/loops/dsl/loop-xml.mjs new file mode 100644 index 00000000..e959c26e --- /dev/null +++ b/src/loops/dsl/loop-xml.mjs @@ -0,0 +1,79 @@ +import { createDiagnostics } from "./diagnostics.mjs"; + +const nameRE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; + +function offsetOf(source, index) { return Buffer.byteLength(source.slice(0, index)); } +function white(char) { return char === " " || char === "\t" || char === "\n" || char === "\r"; } + +/** Parses the intentionally tiny, element-only Stage 1 XML subset. */ +export function parseLoopXml(input, { path = "review.loop" } = {}) { + const d = createDiagnostics(); + let source; + try { source = new TextDecoder("utf-8", { fatal: true }).decode(input); } + catch { d.add(path, 0, "E_XML_UTF8", "Source is not valid UTF-8"); return { diagnostics: d.list, allDiagnostics: d.all }; } + const add = (at, code, message) => d.add(path, offsetOf(source, at), code, message); + let i = 0, root = null, elements = 0; + const stack = []; + const skip = () => { while (white(source[i])) i++; }; + const readName = () => { + const begin = i; while (/[A-Za-z0-9_.:-]/.test(source[i] ?? "")) i++; + const value = source.slice(begin, i); return nameRE.test(value) ? value : ""; + }; + const text = () => { + const begin = i; while (i < source.length && source[i] !== "<") i++; + if (/\S/.test(source.slice(begin, i))) add(begin, "E_XML_TEXT", "Non-whitespace element text is not allowed"); + }; + while (i < source.length) { + if (source[i] !== "<") { text(); continue; } + const begin = i++; + if (source.startsWith("", begin + 4); + add(begin, "E_XML_COMMENT", end < 0 ? "Comments are not allowed (unterminated)" : "Comments are not allowed"); + i = end < 0 ? source.length : end + 3; continue; + } + if (source.startsWith("", i); add(begin, "E_XML_FORBIDDEN", "Declarations, entities, CDATA, and processing instructions are not allowed"); + i = end < 0 ? source.length : end + 1; continue; + } + if (source[i] === "/") { + i++; const name = readName(); skip(); + if (!name || source[i] !== ">") { add(begin, "E_XML_END_TAG", "Malformed end tag"); while (i < source.length && source[i] !== ">") i++; } + if (source[i] === ">") i++; + const open = stack.pop(); + if (!open || open.name !== name) add(begin, "E_XML_MISMATCH", `End tag does not match its opening element`); + continue; + } + const name = readName(); + if (!name) { add(begin, "E_XML_NAME", "Invalid element name"); while (i < source.length && source[i] !== ">") i++; if (source[i] === ">") i++; continue; } + if (name.includes(":")) add(begin, "E_XML_NAMESPACE", "Namespaces are not allowed"); + const attrs = Object.create(null); let selfClosing = false; + while (i < source.length) { + skip(); + if (source.startsWith("/>", i)) { i += 2; selfClosing = true; break; } + if (source[i] === ">") { i++; break; } + const attrAt = i, key = readName(); + if (!key) { add(attrAt, "E_XML_ATTRIBUTE", "Invalid attribute name"); i++; continue; } + if (key.includes(":")) add(attrAt, "E_XML_NAMESPACE", "Namespaces are not allowed"); + skip(); if (source[i] !== "=") { add(attrAt, "E_XML_ATTRIBUTE", `Expected = after attribute ${key}`); continue; } + i++; skip(); const quote = source[i++]; + if (quote !== '"' && quote !== "'") { add(attrAt, "E_XML_ATTRIBUTE", "Attribute values must be quoted"); continue; } + const valueAt = i; while (i < source.length && source[i] !== quote) i++; + if (i >= source.length) { add(attrAt, "E_XML_ATTRIBUTE", "Unterminated attribute value"); break; } + const value = source.slice(valueAt, i++); + if (value.includes("&")) add(valueAt, "E_XML_ENTITY", "Entities are not allowed"); + if (Object.hasOwn(attrs, key)) add(attrAt, "E_XML_DUPLICATE_ATTRIBUTE", `Duplicate attribute ${key}`); + else attrs[key] = value; + } + const node = { name, attrs, children: [], byteOffset: offsetOf(source, begin), selfClosing }; + elements++; + if (elements > 32) add(begin, "E_XML_LIMIT", "XML element limit is 32"); + if (stack.length >= 2) add(begin, "E_XML_DEPTH", "XML depth limit is 2"); + if (stack.length) stack.at(-1).children.push(node); + else if (root) add(begin, "E_XML_ROOT", "Only one root element is allowed"); + else root = node; + if (!selfClosing) stack.push(node); + } + for (const node of stack.reverse()) d.add(path, Buffer.byteLength(source), "E_XML_UNCLOSED", `Unclosed element <${node.name}>`); + if (!root) d.add(path, 0, "E_XML_ROOT", "Document requires a root element"); + return { ast: root, diagnostics: d.list, allDiagnostics: d.all }; +} diff --git a/src/loops/dsl/package-read.mjs b/src/loops/dsl/package-read.mjs new file mode 100644 index 00000000..8f43990c --- /dev/null +++ b/src/loops/dsl/package-read.mjs @@ -0,0 +1,352 @@ +import { constants } from "node:fs"; +import { lstat, opendir, open } from "node:fs/promises"; +import { join } from "node:path"; + +const KNOWN_ROOT = new Set(["review.loop", "instructions.md", "example"]); +const KNOWN_EXAMPLE = new Set(["item.md"]); +const FILE_LIMITS = { "review.loop": 65536, "instructions.md": 262144, "example/item.md": 65536 }; +const DIRECTORY_LIMITS = { "": 3, example: 1 }; +const MAX_PACKAGE_BYTES = 393216; + +const OPEN_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + +function compareUtf8(left, right) { + return Buffer.compare(Buffer.from(left), Buffer.from(right)); +} + +function sameEntry(before, after) { + return Boolean( + before && + after && + before.dev === after.dev && + before.ino === after.ino && + before.mode === after.mode && + before.size === after.size && + before.mtimeNs === after.mtimeNs && + before.ctimeNs === after.ctimeNs, + ); +} + +function addDiagnostic(output, path, code, message) { + output.push({ path, byteOffset: 0, code, message }); +} + +async function readDirectoryEntries(folder, maxEntries, pathLabel, output) { + let dir; + try { + dir = await opendir(folder, { encoding: "utf8" }); + const entries = []; + let overflow = false; + for await (const entry of dir) { + entries.push(entry.name); + if (entries.length > maxEntries) { + overflow = true; + break; + } + } + if (overflow) entries.pop(); + return { + entries: entries.sort(compareUtf8), + overflow, + }; + } catch { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package directory could not be read"); + return { entries: [], overflow: false, readError: true }; + } finally { + await dir?.close?.().catch(() => {}); + } +} + +async function snapshotDirectory(pathLabel, folder, output) { + let stat; + try { + stat = await lstat(folder, { bigint: true }); + } catch { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package directory could not be inspected"); + return null; + } + if (stat.isSymbolicLink()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_SYMLINK", "Package symlinks are not allowed"); + return null; + } + if (!stat.isDirectory()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_DIRECTORY", "Package root must be a directory"); + return null; + } + + const limit = DIRECTORY_LIMITS[pathLabel] ?? Number.MAX_SAFE_INTEGER; + const contents = await readDirectoryEntries(folder, limit, pathLabel, output); + if (contents.readError) { + return { path: pathLabel, stat, entries: [], truncated: false, readError: true }; + } + if (contents.overflow) addDiagnostic(output, pathLabel, "E_PACKAGE_COUNT", "Package may contain at most three files"); + + return { path: pathLabel, stat, entries: contents.entries, truncated: contents.overflow }; +} + +function compareDirectory(before, after, output, isKnownEntry) { + if (!before || !after) return; + + if (!sameEntry(before.stat, after.stat)) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } + if (before.truncated) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } + if (after.truncated && !before.truncated) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } + + const previous = new Set(before.entries); + const current = new Set(after.entries); + for (const name of previous) { + if (!current.has(name)) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } + } + for (const name of current) { + if (previous.has(name)) continue; + if (isKnownEntry(name)) { + addDiagnostic(output, before.path, "E_PACKAGE_RACE", "Package directory changed while reading"); + } else { + addDiagnostic(output, `${before.path ? `${before.path}/` : ""}${name}`, "E_PACKAGE_PATH", "Package entry was discovered after directory enumeration"); + } + } +} + +function toFileSize(value) { + return typeof value === "bigint" ? Number(value) : value; +} + +async function validateAncestors(ancestors, output, failurePath) { + for (const ancestor of ancestors) { + try { + const current = await lstat(ancestor.path, { bigint: true }); + if (!current.isDirectory() || !sameEntry(current, ancestor.stat)) { + addDiagnostic(output, failurePath, "E_PACKAGE_RACE", "Package entry changed while reading"); + return false; + } + } catch { + addDiagnostic(output, failurePath, "E_PACKAGE_RACE", "Package entry changed while reading"); + return false; + } + } + return true; +} + +async function readValidatedFile(snapshot, output, options) { + let handle; + const pathLabel = snapshot.path; + const full = snapshot.full; + let opened; + try { + handle = await open(full, OPEN_FLAGS); + opened = await handle.stat({ bigint: true }); + + if (options.expected && !sameEntry(opened, options.expected)) { + addDiagnostic(output, pathLabel, "E_PACKAGE_RACE", "Package entry changed while reading"); + return null; + } + if (!opened.isFile()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_FILE", "Package entries must be regular files"); + return null; + } + if (toFileSize(opened.size) > toFileSize(options.maxSize)) { + addDiagnostic(output, pathLabel, "E_FILE_SIZE", `${pathLabel} exceeds the package byte limit`); + return null; + } + + if (options.afterLeafOpenForTest) { + await options.afterLeafOpenForTest({ path: pathLabel, full, opened }); + } + + if (!(await validateAncestors(snapshot.ancestors, output, pathLabel))) return null; + + if (options.expected && options.expected.size !== undefined && opened.size !== options.expected.size) { + addDiagnostic(output, pathLabel, "E_PACKAGE_RACE", "Package entry changed while reading"); + return null; + } + + const expectedSize = toFileSize(opened.size); + const bytes = Buffer.allocUnsafe(expectedSize); + let offset = 0; + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset); + if (!result.bytesRead) break; + offset += result.bytesRead; + } + if (offset !== bytes.length) { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package entry could not be read"); + return null; + } + + const after = await handle.stat({ bigint: true }); + if (!sameEntry(opened, after)) { + addDiagnostic(output, pathLabel, "E_PACKAGE_RACE", "Package entry changed while reading"); + return null; + } + if (!(await validateAncestors(snapshot.ancestors, output, pathLabel))) return null; + + return bytes; + } catch { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package entry could not be read"); + return null; + } finally { + await handle?.close?.().catch(() => {}); + } +} + +async function snapshotLeaf(full, pathLabel, output, ancestors, limit, options = {}) { + let stat; + try { + stat = await lstat(full, { bigint: true }); + } catch { + addDiagnostic(output, pathLabel, "E_PACKAGE_READ", "Package entry could not be inspected"); + return null; + } + + if (stat.isSymbolicLink()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_SYMLINK", "Package symlinks are not allowed"); + return null; + } + if (!stat.isFile()) { + addDiagnostic(output, pathLabel, "E_PACKAGE_FILE", "Package entries must be regular files"); + return null; + } + + if (stat.size > limit) { + addDiagnostic(output, pathLabel, "E_FILE_SIZE", `${pathLabel} exceeds the package byte limit`); + return null; + } + + const bytes = await readValidatedFile( + { + path: pathLabel, + full, + ancestors, + stat, + limit, + }, + output, + { + expected: stat, + maxSize: limit, + ...options, + }, + ); + if (!bytes) return null; + + return { + path: pathLabel, + full, + stat, + limit, + content: bytes, + ancestors, + }; +} + +function validateLeafSnapshot(snapshot, output, remainingBytes) { + if (snapshot.stat.size > snapshot.limit || snapshot.stat.size > remainingBytes) { + addDiagnostic(output, snapshot.path, "E_FILE_SIZE", `${snapshot.path} exceeds the package byte limit`); + return false; + } + return true; +} + +async function readLeaf(snapshot, output, options) { + if (options?.beforeLeafRead) { + await options.beforeLeafRead({ path: snapshot.path, full: snapshot.full }); + } + const bytes = await readValidatedFile(snapshot, output, { + expected: snapshot.stat, + maxSize: snapshot.limit, + afterLeafOpenForTest: options?.afterLeafOpenForTest, + }); + if (!bytes) return null; + if (!bytes.equals(snapshot.content)) { + addDiagnostic(output, snapshot.path, "E_PACKAGE_RACE", "Package entry changed while reading"); + return null; + } + return bytes; +} + +async function revalidateLeafBoundary(snapshot, output) { + const bytes = await readValidatedFile(snapshot, output, { + expected: snapshot.stat, + maxSize: snapshot.limit, + }); + if (!bytes) return; + if (!bytes.equals(snapshot.content)) { + addDiagnostic(output, snapshot.path, "E_PACKAGE_RACE", "Package entry changed while reading"); + } +} + +export async function readPackageDirectory(directory, { beforeLeafRead, afterLeafOpenForTest } = {}) { + const diagnostics = []; + const files = {}; + const root = await snapshotDirectory("", directory, diagnostics); + if (!root) return { files, diagnostics }; + + const leaves = []; + let exampleBefore = null; + let examplePath = null; + + for (const name of root.entries) { + const full = join(directory, name); + if (!KNOWN_ROOT.has(name)) { + addDiagnostic(diagnostics, name, "E_PACKAGE_PATH", "Unknown package file"); + continue; + } + + if (name === "example") { + const example = await snapshotDirectory("example", full, diagnostics); + if (!example) continue; + exampleBefore = example; + examplePath = full; + + for (const child of example.entries) { + const path = `example/${child}`; + if (!KNOWN_EXAMPLE.has(child)) { + addDiagnostic(diagnostics, path, "E_PACKAGE_PATH", "Package entry was discovered after directory enumeration"); + continue; + } + const snapshot = await snapshotLeaf(join(full, child), path, diagnostics, [{ path: directory, stat: root.stat }, { path: full, stat: example.stat }], FILE_LIMITS[path]); + if (!snapshot) continue; + leaves.push(snapshot); + } + continue; + } + + const snapshot = await snapshotLeaf(full, name, diagnostics, [{ path: directory, stat: root.stat }], FILE_LIMITS[name]); + if (!snapshot) continue; + leaves.push(snapshot); + } + + const ordered = [...leaves].sort((left, right) => compareUtf8(left.path, right.path)); + let consumed = 0; + for (const snapshot of ordered) { + if (!validateLeafSnapshot(snapshot, diagnostics, MAX_PACKAGE_BYTES - consumed)) continue; + const bytes = await readLeaf(snapshot, diagnostics, { beforeLeafRead, afterLeafOpenForTest }); + if (bytes) { + consumed += bytes.length; + files[snapshot.path] = bytes; + } + } + + const rootAfter = await snapshotDirectory("", directory, diagnostics); + if (rootAfter) { + compareDirectory(root, rootAfter, diagnostics, (name) => KNOWN_ROOT.has(name)); + } + + if (examplePath) { + const exampleAfter = await snapshotDirectory("example", examplePath, diagnostics); + if (exampleAfter) compareDirectory(exampleBefore, exampleAfter, diagnostics, (name) => KNOWN_EXAMPLE.has(name)); + } + + for (const snapshot of ordered) { + await revalidateLeafBoundary(snapshot, diagnostics); + } + + return { files, diagnostics }; +} diff --git a/src/loops/dsl/package-read.test.mjs b/src/loops/dsl/package-read.test.mjs new file mode 100644 index 00000000..c8ca338c --- /dev/null +++ b/src/loops/dsl/package-read.test.mjs @@ -0,0 +1,311 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { lstat, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compileLoopPackage } from "./compile.mjs"; + +const execFileAsync = promisify(execFile); + +const reviewSource = new URL("../../../loops/review/", import.meta.url); + +async function reviewFiles() { + return { + "review.loop": await readFile(new URL("review.loop", reviewSource)), + "instructions.md": await readFile(new URL("instructions.md", reviewSource)), + }; +} + +async function supportsFifo(path) { + try { + await execFileAsync("mkfifo", [path]); + return true; + } catch { + return false; + } +} + +test("descriptor package reads reject symlinks and oversized entries", async () => { + const files = await reviewFiles(); + const folder = await mkdtemp(join(tmpdir(), "burnlist-loop-package-read-")); + try { + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + assert.equal((await compileLoopPackage(folder)).ok, true); + + await rm(join(folder, "example"), { force: true }).catch(() => {}); + await symlink(join(folder, "review.loop"), join(folder, "example")); + let result = await compileLoopPackage(folder); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_SYMLINK")); + + await rm(join(folder, "example")); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), Buffer.alloc(65537, 65)); + result = await compileLoopPackage(folder); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_FILE_SIZE")); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); + +test("descriptor package read hooks run beforeLeafRead before afterLeafOpenForTest", async () => { + const files = await reviewFiles(); + const folder = await mkdtemp(join(tmpdir(), "burnlist-loop-read-hooks-")); + try { + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + const hooks = []; + const result = await compileLoopPackage(folder, { + beforeLeafRead: ({ path }) => hooks.push(`before:${path}`), + afterLeafOpenForTest: ({ path }) => hooks.push(`after:${path}`), + }); + + assert.equal(result.ok, true); + assert.deepEqual(hooks, [ + "before:example/item.md", + "after:example/item.md", + "before:instructions.md", + "after:instructions.md", + "before:review.loop", + "after:review.loop", + ]); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); + +test("descriptor package reads reject same-size rewrites", async () => { + const files = await reviewFiles(); + const outer = await mkdtemp(join(tmpdir(), "burnlist-loop-race-")); + const folder = join(outer, "package"); + const raceState = { + hookReached: false, + changed: false, + restored: false, + preservedInode: false, + preservedSize: false, + }; + + try { + await mkdir(folder); + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + const result = await compileLoopPackage(folder, { + afterLeafOpenForTest: async ({ path }) => { + if (path !== "review.loop") return; + + raceState.hookReached = true; + const target = join(folder, "example", "item.md"); + const beforeBytes = await readFile(target); + const before = await lstat(target); + + const changed = Buffer.from(beforeBytes); + changed[0] = changed[0] === 114 ? 83 : 114; + await writeFile(target, changed); + + const changedBytes = await readFile(target); + const during = await lstat(target); + + await writeFile(target, beforeBytes); + const restoredBytes = await readFile(target); + const restored = await lstat(target); + + raceState.changed = Buffer.compare(beforeBytes, changedBytes) !== 0; + raceState.restored = Buffer.compare(beforeBytes, restoredBytes) === 0; + raceState.preservedInode = before.ino === restored.ino; + raceState.preservedSize = before.size === restored.size; + }, + }); + + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_RACE" && item.path === "example/item.md")); + assert.equal(raceState.hookReached, true); + assert.equal(raceState.changed, true); + assert.equal(raceState.restored, true); + assert.equal(raceState.preservedInode, true); + assert.equal(raceState.preservedSize, true); + } finally { + await rm(outer, { recursive: true, force: true }); + } +}); + +test("descriptor package reads reject root membership races", async () => { + const files = await reviewFiles(); + const outer = await mkdtemp(join(tmpdir(), "burnlist-loop-race-")); + const folder = join(outer, "package"); + const raceState = { + hookReached: false, + added: false, + restored: false, + transientInode: null, + transientSize: null, + transientWasMissing: false, + }; + + try { + await mkdir(folder); + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + const result = await compileLoopPackage(folder, { + afterLeafOpenForTest: async ({ path }) => { + if (path !== "instructions.md") return; + + raceState.hookReached = true; + const transient = join(folder, "transient-entry.md"); + const before = await lstat(transient).catch(() => null); + raceState.transientWasMissing = before === null; + + await writeFile(transient, "unexpected"); + const created = await lstat(transient); + raceState.added = true; + raceState.transientInode = created.ino; + raceState.transientSize = created.size; + + await rm(transient); + const after = await lstat(transient).catch(() => null); + raceState.restored = after === null; + }, + }); + + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_RACE" && item.path === "")); + assert.equal(raceState.hookReached, true); + assert.equal(raceState.transientWasMissing, true); + assert.equal(raceState.added, true); + assert.equal(raceState.restored, true); + assert.equal(typeof raceState.transientInode, "number"); + assert.equal(raceState.transientSize, 10); + } finally { + await rm(outer, { recursive: true, force: true }); + } +}); + +test("descriptor package reads reject swapped example paths without following FIFO", async (t) => { + const files = await reviewFiles(); + const outer = await mkdtemp(join(tmpdir(), "burnlist-loop-example-fifo-")); + const folder = join(outer, "package"); + try { + await mkdir(folder); + await writeFile(join(folder, "review.loop"), files["review.loop"]); + await writeFile(join(folder, "instructions.md"), files["instructions.md"]); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + const fifoTarget = join(outer, "outside-fifo"); + await mkdir(fifoTarget); + if (!(await supportsFifo(join(fifoTarget, "item.md")))) { + t.skip("mkfifo unavailable in test environment"); + return; + } + + const regularTarget = join(outer, "outside-regular"); + await mkdir(regularTarget); + await writeFile(join(regularTarget, "item.md"), files["review.loop"]); + + let firstHookCount = 0; + const fifoStart = Date.now(); + let result = await compileLoopPackage(folder, { + beforeLeafRead: async ({ path }) => { + if (path === "example/item.md") { + firstHookCount += 1; + await rm(join(folder, "example"), { recursive: true, force: true }); + await symlink(fifoTarget, join(folder, "example")); + } + }, + }); + const fifoElapsed = Date.now() - fifoStart; + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_RACE")); + assert.equal(firstHookCount, 1); + assert.ok(fifoElapsed < 2000); + + await rm(join(folder, "example"), { recursive: true, force: true }); + await mkdir(join(folder, "example")); + await writeFile(join(folder, "example", "item.md"), files["review.loop"]); + + let secondHookCount = 0; + const start = Date.now(); + result = await compileLoopPackage(folder, { + beforeLeafRead: async ({ path }) => { + if (path === "example/item.md") { + secondHookCount += 1; + await rm(join(folder, "example"), { recursive: true, force: true }); + await symlink(regularTarget, join(folder, "example")); + } + }, + }); + const elapsed = Date.now() - start; + assert.equal(secondHookCount, 1); + assert.ok(elapsed < 2000); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((item) => item.code === "E_PACKAGE_RACE")); + } finally { + await rm(outer, { recursive: true, force: true }); + } +}); + +test("descriptor package diagnostics are merged and truncated after discovery, missing-file, and compiler findings", async () => { + const files = await reviewFiles(); + const folder = await mkdtemp(join(tmpdir(), "burnlist-loop-package-diagnostics-")); + try { + const badCount = 90; + const extras = Array.from({ length: badCount }, (_, index) => ` bad-${String(index).padStart(3, "0")}="x"`).join(""); + const malformed = files["review.loop"].toString() + .replace('version="0.1.0"', "version=\"0\"") + .replace('max-rounds="3"', `max-rounds="0"${extras}`) + .replace('', ''); + + await writeFile(join(folder, "review.loop"), malformed); + await writeFile(join(folder, "note.md"), "ignored\n"); + + const budgetOffset = malformed.indexOf("'); + + const expected = [ + { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }, + { path: "instructions.md", byteOffset: 0, code: "E_PACKAGE_MISSING", message: "Required package file is missing" }, + { path: "note.md", byteOffset: 0, code: "E_PACKAGE_PATH", message: "Unknown package file" }, + { path: "review.loop", byteOffset: 0, code: "E_SCALAR", message: "version must be a Stage 1 SemVer" }, + ...Array.from({ length: badCount }, (_, index) => ({ + path: "review.loop", + byteOffset: budgetOffset, + code: "E_ATTRIBUTE_UNKNOWN", + message: `Attribute bad-${String(index).padStart(3, "0")} is not allowed on `, + })), + { path: "review.loop", byteOffset: budgetOffset, code: "E_SCALAR", message: "max-rounds must be an integer from 1 through 100" }, + { path: "review.loop", byteOffset: implementOffset, code: "E_EDGE_MISSING", message: "Missing edge for implement/complete" }, + { path: "review.loop", byteOffset: verifyOffset, code: "E_REACHABILITY", message: "Node verify is not reachable from entry" }, + { path: "review.loop", byteOffset: reviewOffset, code: "E_REACHABILITY", message: "Node review is not reachable from entry" }, + { path: "review.loop", byteOffset: convergedOffset, code: "E_REACHABILITY", message: "Node converged is not reachable from entry" }, + { path: "review.loop", byteOffset: convergenceDominanceOffset, code: "E_CONVERGENCE_DOMINATION", message: "Only convergence gate pass may target the converged terminal" }, + ]; + + const result = await compileLoopPackage(folder); + assert.equal(result.ok, false); + assert.equal(result.diagnostics.length, 100); + assert.deepEqual(result.diagnostics, expected); + assert.ok(result.diagnostics.some((item) => item.path === "instructions.md" && item.code === "E_PACKAGE_MISSING")); + assert.ok(result.diagnostics.some((item) => item.path === "note.md" && item.code === "E_PACKAGE_PATH")); + assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_UNKNOWN")); + assert.ok(result.diagnostics.some((item) => item.code === "E_CONVERGENCE_DOMINATION")); + } finally { + await rm(folder, { recursive: true, force: true }); + } +}); diff --git a/src/loops/events/projection-events.mjs b/src/loops/events/projection-events.mjs new file mode 100644 index 00000000..39165e46 --- /dev/null +++ b/src/loops/events/projection-events.mjs @@ -0,0 +1,27 @@ +import { publishCanonicalMutation } from "../../events/oven-canonical-mutations.mjs"; + +export const LOOP_PROJECTION_CHANGED_KIND = "loop-projection-changed"; +export const LOOP_PROJECTION_CHANGED_PHASE = "complete"; + +/** Observational only: the journal commit has already completed when this runs. */ +export function loopProjectionChangedInput({ projection, revision, occurredAt = new Date().toISOString() }) { + if (typeof projection?.itemRef !== "string" || typeof revision !== "string") { + throw new Error("Loop projection event requires a committed Run projection."); + } + return { + ovenId: "checklist", + subjectId: projection.itemRef, + kind: LOOP_PROJECTION_CHANGED_KIND, + phase: LOOP_PROJECTION_CHANGED_PHASE, + cursor: revision, + occurredAt, + payload: { revision }, + }; +} + +export function publishLoopProjectionInvalidation(repoRoot, replay, options) { + return publishCanonicalMutation(repoRoot, loopProjectionChangedInput({ + projection: replay?.projection, + revision: replay?.revision, + }), options); +} diff --git a/src/loops/events/projection-events.test.mjs b/src/loops/events/projection-events.test.mjs new file mode 100644 index 00000000..2d3b2eb9 --- /dev/null +++ b/src/loops/events/projection-events.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loopProjectionChangedInput } from "./projection-events.mjs"; +import { normalizeOvenEvent } from "../../events/oven-event-contract.mjs"; +import { readLatestRunForItem } from "../run/read-projection.mjs"; + +test("Loop invalidations identify only the committed projection revision", () => { + const projection = { itemRef: "item:260722-001#M7", runId: "run:01arz3ndektsv4rrffq69g5fav" }; + const first = loopProjectionChangedInput({ projection, revision: `sha256:${"a".repeat(64)}`, occurredAt: "2026-07-24T10:00:00Z" }); + const second = loopProjectionChangedInput({ projection, revision: `sha256:${"a".repeat(64)}`, occurredAt: "2026-07-24T10:01:00Z" }); + assert.deepEqual(first.payload, { revision: `sha256:${"a".repeat(64)}` }); + assert.equal(first.subjectId, projection.itemRef); + assert.equal(first.cursor, first.payload.revision); + assert.equal(normalizeOvenEvent(first).eventId, normalizeOvenEvent(second).eventId, "retry is deduplicated by revision"); + assert.doesNotMatch(JSON.stringify(first), /currentNode|graph|transition|budget/u); +}); + +test("projection reads ignore only recognized concurrent creation staging directories", async () => { + const root = await mkdtemp(join(tmpdir(), "loop-projection-staging-")); + try { + await mkdir(join(root, ".local", "burnlist", "loop", "m2", "runs", ".create-0123456789abcdef.tmp"), { recursive: true }); + assert.equal(readLatestRunForItem({ repoRoot: root, itemRef: "item:260722-001#M7" }), null); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("projection reads reject excessive recognized creation staging directories", async () => { + const root = await mkdtemp(join(tmpdir(), "loop-projection-staging-bound-")); + try { + const runs = join(root, ".local", "burnlist", "loop", "m2", "runs"); + await Promise.all(Array.from({ length: 129 }, (_, index) => + mkdir(join(runs, `.create-${index.toString(16).padStart(16, "0")}.tmp`), { recursive: true }))); + assert.throws(() => readLatestRunForItem({ repoRoot: root, itemRef: "item:260722-001#M7" }), /exceeds bounds/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/loops/minimal-review-e2e-fixtures.mjs b/src/loops/minimal-review-e2e-fixtures.mjs new file mode 100644 index 00000000..39330539 --- /dev/null +++ b/src/loops/minimal-review-e2e-fixtures.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:http"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cli = join(root, "bin", "burnlist.mjs"); +const server = join(root, "src", "server", "burnlist-dashboard-server.mjs"); + +export function cliJson(repo, args, env = {}) { + const result = spawnSync(process.execPath, [cli, ...args, "--repo", repo], { + cwd: repo, encoding: "utf8", env: { ...process.env, ...env }, + }); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr || result.stdout}`); + assert.equal(result.stderr, "", args.join(" ")); + return JSON.parse(result.stdout); +} + +export function cliOk(repo, args, env = {}) { + const result = spawnSync(process.execPath, [cli, ...args, "--repo", repo], { + cwd: repo, encoding: "utf8", env: { ...process.env, ...env }, + }); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr || result.stdout}`); + assert.equal(result.stderr, "", args.join(" ")); + return result.stdout; +} + +export function startCli(repo, args, env = {}) { + return spawn(process.execPath, [cli, ...args, "--repo", repo], { + cwd: repo, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"], + }); +} + +export async function waitForExit(child) { + const chunks = [[], []]; + child.stdout.on("data", (value) => chunks[0].push(value)); + child.stderr.on("data", (value) => chunks[1].push(value)); + const [code, signal] = await new Promise((done) => child.once("exit", (...value) => done(value))); + return { code, signal, stdout: Buffer.concat(chunks[0]).toString("utf8"), stderr: Buffer.concat(chunks[1]).toString("utf8") }; +} + +export async function waitForFile(path, child, timeout = 5_000) { + const until = Date.now() + timeout; + while (!existsSync(path)) { + if (child.exitCode !== null) throw new Error(`foreground CLI exited before ${path}`); + if (Date.now() >= until) throw new Error(`timed out waiting for ${path}`); + await new Promise((done) => setTimeout(done, 10)); + } +} + +function availablePort() { + return new Promise((resolvePort, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + probe.close((error) => error ? reject(error) : resolvePort(address.port)); + }); + }); +} + +async function waitForServer(child) { + let output = ""; + return new Promise((resolveReady, reject) => { + const timeout = setTimeout(() => reject(new Error(`dashboard did not start: ${output}`)), 8_000); + const ready = (chunk) => { + output += chunk.toString(); + const match = output.match(/http:\/\/127\.0\.0\.1:\d+\//u); + if (!match) return; + clearTimeout(timeout); resolveReady(match[0]); + }; + child.stdout.on("data", ready); child.stderr.on("data", ready); + child.once("exit", (code) => { clearTimeout(timeout); reject(new Error(`dashboard exited ${code}: ${output}`)); }); + }); +} + +export async function withDashboard(repo, action) { + const port = await availablePort(); + const child = spawn(process.execPath, [server, "--port", String(port), "--auto-port", "--scan-root", repo, + "--state-dir", join(repo, ".local", "m9-dashboard")], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); + try { return await action(await waitForServer(child)); } + finally { + if (child.exitCode === null) child.kill("SIGTERM"); + if (child.exitCode === null) await new Promise((done) => child.once("exit", done)); + } +} + +export function request(baseUrl, path, { headers = {} } = {}) { + return fetch(new URL(path, baseUrl), { headers }).then(async (response) => ({ + status: response.status, headers: Object.fromEntries(response.headers), body: await response.text(), + })); +} diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs new file mode 100644 index 00000000..f539a549 --- /dev/null +++ b/src/loops/minimal-review-e2e.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; +import { createProductionRunAuthority, fixtureItemRef } from "./run/run-test-fixtures.mjs"; +import { readLatestRunForItem } from "./run/read-projection.mjs"; +import { runStore } from "./run/run-store.mjs"; +import { readOvenEvents } from "../events/oven-event-store.mjs"; +import { cliJson, cliOk, request, startCli, waitForExit, waitForFile, withDashboard } from "./minimal-review-e2e-fixtures.mjs"; +import { checklistFixture } from "../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; +import { withDeterministicTime } from "../../dashboard/src/oven/test-support/deterministic-time.mjs"; + +const componentPath = new URL("../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx", import.meta.url).pathname; +const normalizerPath = new URL("../../dashboard/src/oven/test-support/dom-normalize.ts", import.meta.url).pathname; +const libPath = new URL("../../dashboard/src/lib", import.meta.url).pathname; +const ovenPath = new URL("../../dashboard/src/oven", import.meta.url).pathname; +const domGoldenPath = new URL("./__fixtures__/minimal-review-e2e-dom.golden.json", import.meta.url); +const digest = (value) => createHash("sha256").update(value).digest("hex"); + +function addUnassignedItem(path, item) { + writeFileSync(path, readFileSync(path, "utf8").replace("\n## Completed", `\n- [ ] ${item}\n\n## Completed`)); +} +function edges(projection) { + return projection.transitions.filter((item) => !["prepared", "running", "paused"].includes(item.from)).map(({ from, outcome, to }) => ({ from, outcome, to })); +} +function liveProjection(baseUrl, planPath, headers = {}) { + return request(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { headers }); +} +async function dashboardRenderer(t) { + const output = await mkdtemp(join(process.cwd(), ".m9-checklist-render-")); + t.after(() => rm(output, { recursive: true, force: true })); + const componentOutput = join(output, "ChecklistDashboard.mjs"), normalizerOutput = join(output, "dom-normalize.mjs"); + await Promise.all([ + build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: componentOutput, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }), + build({ entryPoints: [normalizerPath], bundle: true, format: "esm", outfile: normalizerOutput, platform: "node", target: "node18" }), + ]); + const [{ ChecklistDashboard }, { normalize, parseHtml, serializeCanonical }] = await Promise.all([ + import(`${new URL(`file://${componentOutput}`).href}?m9=${Date.now()}`), + import(`${new URL(`file://${normalizerOutput}`).href}?m9=${Date.now()}`), + ]); + return (checkpoint, loopRun) => { + const candidateAliases = new Map(), alias = (id) => { + if (!id) return id; + if (!candidateAliases.has(id)) candidateAliases.set(id, `candidate-${candidateAliases.size + 1}`); + return candidateAliases.get(id); + }; + const base = Date.parse("2026-07-15T11:00:00Z"); + const stableRun = loopRun && { ...loopRun, createdAt: base, updatedAt: base + 4_000, + latestMaker: loopRun.latestMaker && { ...loopRun.latestMaker, at: base + 1_000, candidateId: alias(loopRun.latestMaker.candidateId) }, + latestCheck: loopRun.latestCheck && { ...loopRun.latestCheck, at: base + 2_000, candidateId: alias(loopRun.latestCheck.candidateId) }, + latestReviewer: loopRun.latestReviewer && { ...loopRun.latestReviewer, at: base + 3_000, candidateId: alias(loopRun.latestReviewer.candidateId) } }; + const dom = serializeCanonical(normalize(parseHtml(withDeterministicTime(() => + renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, loopRun: stableRun } })) )))); + return { record: { checkpoint, domBytes: Buffer.byteLength(dom), domSha256: digest(dom) }, dom }; + }; +} + +test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch, UI states, escalation, and completion", { timeout: 60_000 }, async (t) => { + const directory = mkdtempSync(join(tmpdir(), "burnlist-m9-e2e-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); + addUnassignedItem(planPath, "DIRECT-01 | Unassigned direct-flow control"); + const render = await dashboardRenderer(t); + + await withDashboard(repo, async (baseUrl) => { + const absent = await liveProjection(baseUrl, planPath); + assert.equal(absent.status, 200); assert.equal(JSON.parse(absent.body).loopRun, null); + const view = cliOk(repo, ["loop", "view", fixtureItemRef]); + assert.match(view, /^MODE: ITEM-PINNED$/mu); assert.match(view, /implement.*verify.*review/su); + + const escalation = cliJson(repo, ["loop", "create", fixtureItemRef]).runId, escalationCounter = join(directory, "escalation-counter"); + writeFileSync(escalationCounter, "0"); + const escalated = cliJson(repo, ["loop", "run", escalation], { + BURNLIST_FAKE_COUNTER: escalationCounter, BURNLIST_FAKE_OUTCOMES: "complete,escalate", + }); + assert.equal(escalated.state, "needs-human"); + const escalationInspection = cliJson(repo, ["loop", "inspect", escalation]); + assert.deepEqual(edges(escalationInspection), [ + { from: "implement", outcome: "complete", to: "verify" }, { from: "verify", outcome: "pass", to: "review" }, + { from: "review", outcome: "escalate", to: "needs-human" }, + ]); + const escalationHttp = await liveProjection(baseUrl, planPath); + assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; + assert.deepEqual(escalationProjection, escalationInspection); + const needsHumanUi = render("needs-human", escalationProjection); + assert.match(needsHumanUi.dom, /aria-label="Loop state: Needs human review"/u); + assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(escalation).toString("hex"), "completion-receipt.json")), false); + assert.match(readFileSync(planPath, "utf8"), /- \[ \] L29/u); + + const runId = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; + const counter = join(directory, "counter"), started = join(directory, "started.json"); + writeFileSync(counter, "0"); + const first = startCli(repo, ["loop", "run", runId], { + BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve", + BURNLIST_FAKE_STARTED: started, BURNLIST_FAKE_WAIT_MS: "1000", + }); + await waitForFile(started, first); + const active = JSON.parse(readFileSync(started, "utf8")); + assert.equal(existsSync(`${started}.${active.pid}.tmp`), false, "ready marker is atomically published"); + assert.equal(active.node, "implement"); assert.equal(first.kill("SIGINT"), true); + const interrupted = await waitForExit(first); + assert.equal(interrupted.code, 0, interrupted.stderr); + const paused = JSON.parse(interrupted.stdout); + assert.equal(paused.state, "paused"); assert.equal(paused.currentNode, "implement"); assert.equal(paused.attempt, 1); + assert.throws(() => process.kill(active.pid, 0), { code: "ESRCH" }); + const pausedInspection = cliJson(repo, ["loop", "inspect", runId]); + const pausedStatus = cliJson(repo, ["loop", "status", runId]); + for (const projection of [pausedInspection, pausedStatus]) { + assert.equal(projection.loopId, "review"); + assert.match(projection.loopRevision, /^er1-sha256:[a-f0-9]{64}$/u); + assert.equal(Number.isSafeInteger(projection.createdAt), true); + assert.equal(Number.isSafeInteger(projection.updatedAt), true); + assert.ok(projection.updatedAt >= projection.createdAt); + } + assert.equal(pausedStatus.state, "paused"); assert.equal(pausedStatus.currentNode, "implement"); + assert.equal(pausedInspection.latestResult, null); + assert.equal(pausedInspection.transitions.length, 2, "only prepared→running and running→paused are durable"); + const pausedRaw = runStore(repo).read(runId); + const pausedPrefix = pausedRaw.journal.map((record) => record.bytes.toString("utf8")); + const firstImplement = pausedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement" && record.value.payload.attempt === 1); + assert.equal(firstImplement.length, 1); assert.equal(pausedRaw.journal.filter((record) => record.value.type === "invocation-result" && record.value.payload.invocationId === firstImplement[0].value.payload.invocationId).length, 0); + assert.equal(pausedRaw.execution.invocation, null); assert.equal(pausedRaw.projection.leaseHeld, false); + const pausedHttp = await liveProjection(baseUrl, planPath); + assert.equal(pausedHttp.status, 200); const pausedProjection = JSON.parse(pausedHttp.body).loopRun; + assert.deepEqual(pausedProjection, pausedInspection); + const afterPause = await liveProjection(baseUrl, planPath, { "if-none-match": pausedHttp.headers.etag }); + assert.equal(afterPause.status, 304); + + const repairStarted = join(directory, "repair-started.json"); + const repair = startCli(repo, ["loop", "resume", runId], { + BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve", + BURNLIST_FAKE_STARTED: repairStarted, BURNLIST_FAKE_WAIT_MS: "1000", + }); + for (;;) { + await waitForFile(repairStarted, repair); + const marker = JSON.parse(readFileSync(repairStarted, "utf8")); + if (marker.node === "implement" && marker.attempt === 2) { repair.kill("SIGINT"); break; } + await new Promise((done) => setTimeout(done, 10)); + } + const repairExit = await waitForExit(repair); + assert.equal(repairExit.code, 0, repairExit.stderr); + const repairProjection = JSON.parse(repairExit.stdout); + assert.equal(repairProjection.state, "paused"); assert.equal(repairProjection.currentNode, "implement"); + assert.equal(repairProjection.attempt, 2); assert.deepEqual(repairProjection.latestResult, { kind: "reject", summary: "fake reject" }); + const repairHttp = await liveProjection(baseUrl, planPath, { "if-none-match": pausedHttp.headers.etag }); + assert.equal(repairHttp.status, 200); assert.deepEqual(JSON.parse(repairHttp.body).loopRun, repairProjection); + + const completed = cliJson(repo, ["loop", "resume", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve" }); + assert.equal(completed.state, "converged"); assert.equal(completed.currentNode, "completed"); + assert.deepEqual(edges(completed), [ + { from: "implement", outcome: "complete", to: "verify" }, { from: "verify", outcome: "pass", to: "review" }, + { from: "review", outcome: "reject", to: "implement" }, { from: "implement", outcome: "complete", to: "verify" }, + { from: "verify", outcome: "pass", to: "review" }, { from: "review", outcome: "approve", to: "converged" }, + { from: "converged", outcome: "pass", to: "completed" }, + ]); + const completedRaw = runStore(repo).read(runId); + assert.deepEqual(completedRaw.journal.slice(0, pausedPrefix.length).map((record) => record.bytes.toString("utf8")), pausedPrefix); + const implementInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement"); + const checkInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "verify"); + const reviewerInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "review"); + assert.equal(implementInvocations.length, 4); assert.equal(reviewerInvocations.length, 2); + assert.deepEqual(implementInvocations.map((record) => record.value.payload.attempt), [1, 1, 2, 2]); + const candidates = completedRaw.journal.filter((record) => record.value.type === "candidate-bound").map((record) => record.value.payload.candidateId); + assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1], "repair publishes a fresh repository candidate"); + const resultCandidate = (startedRecord) => completedRaw.journal.find((record) => + record.value.type === "invocation-result" && record.value.payload.invocationId === startedRecord.value.payload.invocationId)?.value.payload.candidateId; + assert.deepEqual(checkInvocations.map(resultCandidate), candidates, "each trusted check is bound to its maker candidate"); + assert.deepEqual(reviewerInvocations.map(resultCandidate), candidates, "each reviewer result is bound to its checked candidate"); + const invocationIds = [...implementInvocations, ...reviewerInvocations].map((record) => record.value.payload.invocationId); + assert.equal(new Set(invocationIds).size, invocationIds.length, "every agent invocation id is globally unique"); + assert.equal(readFileSync(counter, "utf8"), "4"); + const beforeRestart = cliJson(repo, ["loop", "inspect", runId]); + assert.deepEqual(cliJson(repo, ["loop", "run", runId]), completed, "terminal restart is an idempotent read"); + assert.deepEqual(cliJson(repo, ["loop", "inspect", runId]), beforeRestart, "terminal restart writes no journal records"); + const convergedHttp = await liveProjection(baseUrl, planPath, { "if-none-match": repairHttp.headers.etag }); + assert.equal(convergedHttp.status, 200); const convergedProjection = JSON.parse(convergedHttp.body).loopRun; + assert.deepEqual(convergedProjection, completed); + const replay = readLatestRunForItem({ repoRoot: repo, itemRef: fixtureItemRef }); + assert.deepEqual(replay, completed, "invalidation consumers refetch the canonical current Run"); + const invalidations = readOvenEvents(repo, { ovenIds: ["checklist"] }).filter((event) => event.kind === "loop-projection-changed" && event.cursor === completed.revision); + assert.equal(invalidations.length, 1); assert.deepEqual(invalidations[0].payload, { revision: completed.revision }); + + const ui = [render("paused", pausedProjection), render("repair", repairProjection), render("converged", convergedProjection)]; + const domGolden = JSON.parse(await readFile(domGoldenPath, "utf8")); + assert.deepEqual(needsHumanUi.record, domGolden[0]); + assert.deepEqual(ui.map((item) => item.record), domGolden.slice(1, 4)); + + const firstCompletion = cliJson(repo, ["loop", "complete", runId]); + const secondCompletion = cliJson(repo, ["loop", "complete", runId]); + assert.equal(firstCompletion.alreadyApplied, false); assert.equal(secondCompletion.alreadyApplied, true); + assert.deepEqual(cliJson(repo, ["loop", "inspect", runId]), beforeRestart, "completion owns no journal mutation"); + const plan = readFileSync(planPath, "utf8"); + assert.equal((plan.match(/^- L29 \| /gmu) ?? []).length, 1); assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(runId).toString("hex"), "completion-intent.json")), false); + assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(runId).toString("hex"), "completion-receipt.json")), true); + assert.equal(readOvenEvents(repo, { ovenIds: ["checklist"] }).filter((event) => event.kind === "item-burned" && event.subjectId === "260722-001" && event.payload.itemId === "L29").length, 1); + const post = await liveProjection(baseUrl, planPath); + assert.equal(post.status, 200); assert.equal(JSON.parse(post.body).loopRun, null); + assert.deepEqual(render("post-completion", null).record, domGolden.at(-1)); + + cliOk(repo, ["burn", "260722-001", "DIRECT-01"]); + assert.match(readFileSync(planPath, "utf8"), /^- DIRECT-01 \| .* \| Unassigned direct-flow control$/mu); + }); +}); diff --git a/src/loops/run/binder.mjs b/src/loops/run/binder.mjs new file mode 100644 index 00000000..f1d6a71b --- /dev/null +++ b/src/loops/run/binder.mjs @@ -0,0 +1,361 @@ +import { lstatSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; +import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; +import { parseItemRef } from "../assignment/selectors.mjs"; +import { assignmentStore } from "../assignment/store.mjs"; +import { readCapabilityCatalog, resolveCapability, canonicalCapabilityBytes, canonicalGrantBytes, GUARANTEE_LABELS } from "../capabilities/contract.mjs"; +import { assertTrustedCapability } from "../capabilities/trust.mjs"; +import { checkSnapshot, holdSnapshot, readSnapshotBytes, releaseSnapshot, snapshotTarget } from "../capabilities/snapshot.mjs"; +import { compileLoopFiles } from "../dsl/compile.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { createNormalizedInvocation } from "../adapters/normalized-invocation.mjs"; +import { agentProfileRevision } from "../agents/profile.mjs"; +import { readProfile, readRoute, requiredRoutes } from "../config/profiles.mjs"; +import { localRecordPath } from "../config/store.mjs"; +import { boundPolicyRevision, canonicalBoundPolicyBytes, loadBoundPolicy } from "./run-artifacts.mjs"; +import { createRunRunner } from "./runner.mjs"; +import { deriveCandidate } from "./candidate.mjs"; +import { ownerClaimId } from "./run-claim.mjs"; +import { newRunId } from "./run-codec.mjs"; + +const INPUT_KEYS = new Set(["runId", "itemRef"]); +const builtinsRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops"); + +function fail(message, code = "ELOOP_RUN_BINDING") { + throw Object.assign(new Error(`Loop Run binder: ${message}`), { code }); +} +function closed(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function exactInput(value) { + if (!value || typeof value !== "object" || Array.isArray(value) + || Object.keys(value).some((key) => !INPUT_KEYS.has(key)) + || typeof value.itemRef !== "string") fail("creation accepts only runId and itemRef"); + return value; +} +function identity(path, kind = "file") { + const value = lstatSync(path); + const directory = kind.startsWith("directory"); + if (value.isSymbolicLink() || kind === "file" && !value.isFile() || directory && !value.isDirectory()) + fail(`authority input has invalid type: ${path}`); + return Object.freeze(directory + ? { kind, path, dev: String(value.dev), ino: String(value.ino), size: String(value.size), + mode: String(value.mode), mtimeMs: String(value.mtimeMs), ctimeMs: String(value.ctimeMs) } + : { kind, path, dev: String(value.dev), ino: String(value.ino), size: String(value.size), + mode: String(value.mode), mtimeMs: String(value.mtimeMs), ctimeMs: String(value.ctimeMs) }); +} +function boundaryEvidence(paths) { + return Object.freeze([...new Set(paths)].sort().map((path) => identity(path))); +} +function assignmentAncestorEvidence(repoRoot, artifactPath) { + const root = resolve(repoRoot), output = []; + for (let current = dirname(artifactPath); current.startsWith(`${root}/`) || current === root; current = dirname(current)) { + output.push(identity(current, "directory-identity")); + if (current === root) break; + } + return output; +} +function assertBoundaryEvidence(evidence) { + if (!Array.isArray(evidence) || !evidence.length) fail("authority boundary evidence is missing"); + for (const expected of evidence) { + const observed = identity(expected.path, expected.kind); + const keys = expected.kind === "directory-identity" ? ["dev", "ino", "mode"] : ["dev", "ino", "size", "mode", "mtimeMs", "ctimeMs"]; + for (const key of keys) + if (observed[key] !== expected[key]) fail(`authority input changed before Run publication: ${expected.path}`, "ELOOP_RUN_BINDING_STALE"); + } +} +function executableSnapshot(loopRef) { + if (loopRef !== "loop:builtin:review") fail(`executable source identity is unavailable for ${loopRef}`); + const directory = join(builtinsRoot, "review"), files = {}, evidence = []; + for (const [name, maximum] of [["review.loop", 65536], ["instructions.md", 262144]]) { + const path = join(directory, name), captured = readSnapshotBytes({ root: builtinsRoot, path, maximum }); + files[name] = captured.bytes; + evidence.push(Object.freeze({ kind: "file", path, dev: String(captured.identity.dev), ino: String(captured.identity.ino), + size: String(captured.identity.size), mode: String(captured.identity.mode), + mtimeMs: String(captured.identity.mtimeMs), ctimeMs: String(captured.identity.ctimeMs) })); + for (const ancestor of captured.ancestors) evidence.push(Object.freeze({ kind: "directory", path: ancestor.path, + dev: String(ancestor.identity.dev), ino: String(ancestor.identity.ino), size: String(ancestor.identity.size), + mode: String(ancestor.identity.mode), mtimeMs: String(ancestor.identity.mtimeMs), ctimeMs: String(ancestor.identity.ctimeMs) })); + } + const compiled = compileLoopFiles(files); + if (!compiled.ok) fail("captured executable source does not compile"); + assertBoundaryEvidence(evidence); + return { compiled, evidence }; +} +function currentPolicy(repoRoot, recipeRevision) { + const authorityInputs = []; + const add = (role, path, executable = false) => authorityInputs.push(Object.freeze({ role, path, executable })); + const routes = requiredRoutes.map(({ route }) => { + const routeRecord = readRoute({ repoRoot, route }); + const profile = readProfile({ repoRoot, slug: routeRecord.profile }); + add(`route:${route}`, localRecordPath(repoRoot, "routes", route.replace(".", "-"))); + add(`profile:${route}`, localRecordPath(repoRoot, "profiles", profile.id)); + add(`adapter:${route}`, profile.binary, true); + const executableDigest = snapshotTarget({ root: dirname(profile.binary), path: profile.binary }).digest; + return { route, profile, profileRevision: agentProfileRevision(profile), executableDigest, + guarantees: route === "review.strong" + ? { freshSession: "enforced", filesystemWriteDeny: "supervised" } + : { freshSession: "enforced" } }; + }).sort((left, right) => Buffer.compare(Buffer.from(left.route), Buffer.from(right.route))); + const resolved = resolveCapability(readCapabilityCatalog(repoRoot), "repo-verify"); + const trust = assertTrustedCapability({ repoRoot, resolved }); + add("capability-catalog", join(repoRoot, ".burnlist", "loop-capabilities.json")); + add("capability-trust", localRecordPath(repoRoot, "capabilities", resolved.policy.id)); + add("capability-bin", resolved.policy.argv[0], true); + const grants = trust.grants; + const policy = { schema: "burnlist-loop-bound-policy@1", recipeRevision, routes, + capabilities: [{ id: resolved.policy.id, policy: resolved.policy, revision: resolved.revision, + policyDigest: rawSha256(canonicalCapabilityBytes(resolved.policy)), grants, + grantsDigest: rawSha256(canonicalGrantBytes(grants, resolved.policy)), trust, + guarantees: GUARANTEE_LABELS }] }; + const bytes = canonicalBoundPolicyBytes(policy); + return { policy: loadBoundPolicy(bytes).policy, bytes, authorityInputs: Object.freeze(authorityInputs.sort((left, right) => Buffer.compare(Buffer.from(left.role), Buffer.from(right.role)))) }; +} +function liveAuthorityEvidence(inputs) { + return Object.freeze(inputs.map(({ role, path, executable }) => { + const snapshot = snapshotTarget({ root: dirname(path), path }); + if (executable && (snapshot.identity.mode & 0o111) === 0) + fail(`launch executable is not executable: ${path}`, "ELOOP_RUN_BINDING_STALE"); + return Object.freeze({ role, executable, snapshot }); + })); +} +function assertLiveAuthorityEvidence(evidence) { + if (!Array.isArray(evidence) || !evidence.length) fail("live launch authority evidence is missing"); + for (const item of evidence) { + if (!closed(item, ["role", "executable", "snapshot"]) || typeof item.role !== "string" || !item.role || typeof item.executable !== "boolean") fail("invalid live launch authority evidence"); + const snapshot = item.snapshot, file = snapshot?.kind === "file"; + if (!closed(snapshot, file ? ["root", "path", "kind", "ancestors", "identity", "digest", "maximum"] : ["root", "path", "kind", "ancestors", "identity"]) + || !Array.isArray(snapshot.ancestors) || !closed(snapshot.identity, ["dev", "ino", "size", "mode", "mtimeMs", "ctimeMs"]) + || snapshot.ancestors.some((ancestor) => !closed(ancestor, ["path", "identity"]) || !closed(ancestor.identity, ["dev", "ino", "size", "mode", "mtimeMs", "ctimeMs"]))) fail("invalid live launch authority evidence"); + checkSnapshot(item.snapshot); + if (item.executable && (item.snapshot.identity.mode & 0o111) === 0) fail(`launch executable is not executable: ${item.snapshot.path}`, "ELOOP_RUN_BINDING_STALE"); + } +} +export function launchAuthorityDigest(evidence) { + const inputs = evidence.map(({ role, executable, snapshot }) => ({ role, executable, root: snapshot.root, path: snapshot.path, + kind: snapshot.kind, ancestors: snapshot.ancestors.map((ancestor) => ({ path: ancestor.path, identity: ancestor.identity })), + identity: snapshot.identity, digest: snapshot.digest ?? null, maximum: snapshot.maximum ?? null })); + return rawSha256(Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-launch-authority@1", inputs })}\n`)); +} +export function assertExecutableBinding(authority) { + if (!authority?.artifact?.executionRevision || !authority.currentCompiled?.revisions?.executable) + fail("installed executable recipe is unavailable"); + if (authority.currentCompiled.revisions.executable !== authority.artifact.executionRevision) + fail(`installed executable ${authority.currentCompiled.revisions.executable} does not match assignment pin ${authority.artifact.executionRevision}`, "ELOOP_RUN_EXECUTABLE_DRIFT"); + return authority.artifact.executionRevision; +} + +/** Read-only production authority. It never writes assignment, setup, trust, or source files. */ +export async function bindRunCreation({ repoRoot, input }) { + const request = exactInput(input); + const item = parseItemRef(request.itemRef), located = findBurnlistDir(repoRoot, item.burnlistId); + const span = locateItemSpan(readFileSync(join(located.dir, "burnlist.md")), item.itemId); + const metadata = validateAssignedItem(item.selector, span), artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); + if (artifact.assignmentId !== metadata["Assignment-Id"] || artifact.itemRef !== item.selector + || artifact.selector !== metadata.Selector || artifact.assignedItemDigest !== metadata.assignedDigest + || artifact.unassignedItemDigest !== metadata.unassignedDigest + || artifact.executionRevision !== metadata["Execution-Revision"] + || artifact.packageRevision !== metadata["Package-Revision"]) fail("Run creation requires one canonical item assignment"); + const source = executableSnapshot(artifact.selector); + const recipeRevision = assertExecutableBinding({ artifact, currentCompiled: source.compiled }); + const policy = currentPolicy(repoRoot, recipeRevision); + const evidence = boundaryEvidence([join(located.dir, "burnlist.md"), + join(artifact.path, "manifest.json"), join(artifact.path, "recipe.frozen"), ...policy.authorityInputs.map((input) => input.path)]); + const value = Object.freeze({ runId: request.runId, assignmentId: artifact.assignmentId, + itemRef: artifact.itemRef, itemRevision: artifact.assignedItemDigest, + itemText: metadata.unassignedSpan.toString("utf8"), + frozenRecipeBytes: Buffer.from(artifact.frozenRecipeBytes), policyBytes: Buffer.from(policy.bytes), + boundaryEvidence: Object.freeze([...evidence, ...assignmentAncestorEvidence(repoRoot, artifact.path), ...source.evidence]) }); + return value; +} + +function sealRunAuthority(runId, authority) { + if (!authority || authority.runId !== runId || !Buffer.isBuffer(authority.frozenRecipeBytes) || !Buffer.isBuffer(authority.policyBytes)) fail("invalid production runner authority"); + return Object.freeze({ schema: "burnlist-loop-m12-run-authority@1", runId, assignmentId: authority.assignmentId, + itemRef: authority.itemRef, itemRevision: authority.itemRevision, itemText: authority.itemText, + frozenRecipe: authority.frozenRecipeBytes.toString("base64"), policy: authority.policyBytes.toString("base64") }); +} +function unsealRunAuthority(authority) { + if (!authority || authority.schema !== "burnlist-loop-m12-run-authority@1") fail("sealed production authority is unavailable"); + return Object.freeze({ runId: authority.runId, assignmentId: authority.assignmentId, itemRef: authority.itemRef, + itemRevision: authority.itemRevision, itemText: authority.itemText, frozenRecipeBytes: Buffer.from(authority.frozenRecipe, "base64"), + policyBytes: Buffer.from(authority.policy, "base64") }); +} + +/** The one production creation path seals all dispatch inputs before the Run exists. */ +export async function createProductionRun({ repoRoot, store, itemRef, runId = newRunId() }) { + if (!store?.createRun || typeof repoRoot !== "string" || typeof itemRef !== "string") fail("invalid production Run creation"); + let authority = await bindRunCreation({ repoRoot, input: { runId, itemRef } }); + // A reservation is durable before its Run directory is published. A normal + // CLI retry has no RunRef to repeat, so recover only an absent reservation + // for this exact still-bound assignment; every other current Run remains the + // store's normal admission decision. + const reserved = store.readCurrentRun?.(authority.itemRef); + if (reserved && reserved.runId !== runId && reserved.assignmentId === authority.assignmentId) { + try { store.read(reserved.runId); } + catch (error) { + if (error?.code !== "ENOENT") throw error; + runId = reserved.runId; + authority = await bindRunCreation({ repoRoot, input: { runId, itemRef } }); + } + } + revalidatePreparedBinding({ repoRoot, bound: authority }); + const graph = loadFrozenRecipe(authority.frozenRecipeBytes).ir; + store.createRun({ runId, itemRef: authority.itemRef, graph, authority: sealRunAuthority(runId, authority) }); + return store.read(runId); +} + +/** Final synchronous publication boundary over the exact assignment and live policy. */ +export function revalidatePreparedBinding({ repoRoot, bound }) { + assertBoundaryEvidence(bound.boundaryEvidence); + const item = parseItemRef(bound.itemRef); + const located = findBurnlistDir(repoRoot, item.burnlistId); + const span = locateItemSpan(readFileSync(join(located.dir, "burnlist.md")), item.itemId); + const metadata = validateAssignedItem(item.selector, span); + const artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); + if (artifact.assignmentId !== bound.assignmentId || artifact.itemRef !== bound.itemRef + || artifact.assignedItemDigest !== bound.itemRevision + || metadata.unassignedSpan.toString("utf8") !== bound.itemText + || !artifact.frozenRecipeBytes.equals(bound.frozenRecipeBytes) + || metadata["Execution-Revision"] !== artifact.executionRevision) + fail("assignment changed before Run publication", "ELOOP_RUN_BINDING_STALE"); + const policy = currentPolicy(repoRoot, artifact.executionRevision); + if (!policy.bytes.equals(bound.policyBytes)) + fail("configured authority changed before Run publication", "ELOOP_RUN_BINDING_STALE"); + assertBoundaryEvidence(bound.boundaryEvidence); + return true; +} +function revalidateAssignedItem({ repoRoot, replay }) { + const item = parseItemRef(replay.projection.itemRef), located = findBurnlistDir(repoRoot, item.burnlistId); + if (located.lifecycle.folder !== "inprogress") fail("Run item is no longer active", "ELOOP_RUN_BINDING_STALE"); + const span = locateItemSpan(readFileSync(join(located.dir, "burnlist.md")), item.itemId); + const metadata = validateAssignedItem(item.selector, span), artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); + const expectedSelector = `loop:builtin:${replay.frozenRecipe.ir.id}`; + if (metadata.assignedDigest !== replay.projection.itemRevision + || metadata["Assignment-Id"] !== replay.projection.assignmentId + || metadata.Selector !== expectedSelector + || metadata["Execution-Revision"] !== replay.frozenRecipe.revisions.executable + || metadata["Package-Revision"] !== replay.frozenRecipe.revisions.package + || artifact.assignmentId !== replay.projection.assignmentId || artifact.itemRef !== item.selector + || artifact.assignedItemDigest !== replay.projection.itemRevision || artifact.selector !== expectedSelector + || artifact.executionRevision !== replay.frozenRecipe.revisions.executable + || artifact.packageRevision !== replay.frozenRecipe.revisions.package) + fail("Run item assignment changed after creation", "ELOOP_RUN_BINDING_STALE"); +} + +/** Launch boundary: live setup/trust is compared with frozen policy; Loop source is not read. */ +function acceptCurrentPolicy({ repoRoot, replay, current }) { + if (!replay?.frozenRecipe?.revisions?.executable || !replay.boundPolicy || !Buffer.isBuffer(replay.policyBytes)) + fail("verified frozen Run authority is required"); + if (!current.bytes.equals(replay.policyBytes)) fail("configured authority changed after Run creation", "ELOOP_RUN_BINDING_STALE"); + revalidateAssignedItem({ repoRoot, replay }); + return current; +} + +export function revalidateRunBinding({ repoRoot, replay }) { + const current = currentPolicy(repoRoot, replay.frozenRecipe.revisions.executable); + return acceptCurrentPolicy({ repoRoot, replay, current }).policy; +} + +/** Capture live launch inputs inside the canonical launch lock; callers never supply this evidence. */ +export function captureRunLaunchBinding({ repoRoot, replay }) { + const current = currentPolicy(repoRoot, replay.frozenRecipe.revisions.executable); + acceptCurrentPolicy({ repoRoot, replay, current }); + const evidence = liveAuthorityEvidence(current.authorityInputs); + assertLiveAuthorityEvidence(evidence); + return Object.freeze({ evidence, authorityDigest: launchAuthorityDigest(evidence) }); +} + +/** Recheck the exact captured descriptors, ancestors, and digests at the final launch boundary. */ +export function recheckRunLaunchBinding(captured) { + if (!captured || Object.keys(captured).length !== 2 || typeof captured.authorityDigest !== "string") fail("invalid private launch authority evidence"); + assertLiveAuthorityEvidence(captured.evidence); + if (captured.authorityDigest !== launchAuthorityDigest(captured.evidence)) fail("private launch authority digest changed"); +} +export function holdRunLaunchBinding(captured) { + if (!captured || Object.keys(captured).length !== 2 || typeof captured.authorityDigest !== "string") fail("invalid private launch authority evidence"); + const held = []; + try { for (const { snapshot } of captured.evidence) held.push(holdSnapshot(snapshot)); return Object.freeze(held); } + catch (error) { try { releaseRunLaunchBinding(held); } catch {} throw error; } +} +export function releaseRunLaunchBinding(held) { + if (!Array.isArray(held)) fail("invalid held launch authority"); + let failure; + for (const item of held) { + try { releaseSnapshot(item); } + catch (error) { failure ??= error; } + } + if (failure) throw failure; +} + +/** + * Build the production M3 callback from already-frozen Run authority. This is + * deliberately a direct Codex path: Docker controllers are legacy setup + * artifacts and are not consulted for foreground Stage One dispatch. + */ +export function createBoundNormalizedInvocation({ repoRoot, replay, contextFor, startAgent, runCheck, agentTimeoutMs = 0 }) { + if (typeof repoRoot !== "string" || !replay?.projection?.assignmentId || !replay?.frozenRecipe?.ir + || typeof replay.itemText !== "string" || !replay.itemText + || !Buffer.isBuffer(replay.policyBytes) || typeof contextFor !== "function") fail("invalid production invocation input"); + const policy = loadBoundPolicy(replay.policyBytes).policy; + const route = (name) => policy.routes.find((entry) => entry.route === name); + const implementation = route("implementation.standard"), review = route("review.strong"); + if (!implementation || !review) fail("frozen Stage One routes are unavailable"); + const nodes = new Map(replay.frozenRecipe.ir.nodes.map((node) => [node.id, node])); + return createNormalizedInvocation({ repoRoot, nodes, + routes: { implementation: { profile: implementation.profile }, review: { profile: review.profile } }, + bindingFor(invocation, node) { + const context = contextFor(invocation, node), instruction = replay.frozenRecipe.instructions + .find((item) => item.id === node.instructions); + if (!context || node.kind === "agent" && !instruction) fail("frozen invocation context is unavailable"); + return { claimId: context.claimId, assignmentId: replay.projection.assignmentId, + recipeRevision: replay.frozenRecipe.revisions.executable, policyRevision: boundPolicyRevision(policy), + inputCandidate: context.inputCandidate, instructionBytes: instruction + ? Buffer.from(instruction.base64, "base64").toString("utf8") : "Run the frozen trusted capability.\n", + itemText: replay.itemText, candidateContext: context.candidateContext, + reviewerEvidence: context.reviewerEvidence ?? [] }; + }, startAgent, runCheck, agentTimeoutMs }); +} + +/** Compose frozen creation authority, the M3 dispatcher, and the M2 runner. */ +export function createProductionRunRunner({ repoRoot, store, runId, authority, contextFor, + startAgent, runCheck, agentTimeoutMs = 0 }) { + if (authority?.schema === "burnlist-loop-m12-run-authority@1") authority = unsealRunAuthority(authority); + if (!store?.replay || !authority?.assignmentId || !Buffer.isBuffer(authority.frozenRecipeBytes) + || !Buffer.isBuffer(authority.policyBytes)) fail("invalid production runner authority"); + const frozenRecipe = loadFrozenRecipe(authority.frozenRecipeBytes); + const replay = { projection: { assignmentId: authority.assignmentId }, frozenRecipe, + policyBytes: authority.policyBytes, itemText: authority.itemText }; + const liveContext = (invocation, node) => { + const execution = store.replay(runId).execution; + const candidate = execution.candidate ?? deriveCandidate({ repoRoot }); + const checkNode = frozenRecipe.ir.nodes.find((item) => item.kind === "check"); + const check = checkNode && execution.evidence[checkNode.id]; + const reviewerEvidence = node.mode === "review" + ? check?.kind === "pass" && check.candidateId === candidate.id && execution.latest.check?.candidateId === candidate.id + ? [`trusted-check candidate=${candidate.id} summary=${execution.latest.check.summary}`] : [] + : []; + return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, attempt: invocation.attempt, + assignmentId: authority.assignmentId, inputCandidate: candidate.id }), inputCandidate: candidate.id, + candidateContext: candidate.context, reviewerEvidence }; + }; + const dispatch = createBoundNormalizedInvocation({ repoRoot, replay, contextFor: contextFor ?? liveContext, startAgent, runCheck, agentTimeoutMs }); + const invoke = async (invocation) => { + const captured = captureRunLaunchBinding({ repoRoot, replay: { ...replay, projection: { ...replay.projection, itemRef: authority.itemRef, itemRevision: authority.itemRevision }, boundPolicy: loadBoundPolicy(authority.policyBytes).policy } }); + recheckRunLaunchBinding(captured); const held = holdRunLaunchBinding(captured); + try { recheckRunLaunchBinding(captured); return await dispatch(invocation); } + finally { releaseRunLaunchBinding(held); } + }; + return createRunRunner({ store, runId, invoke, bindCandidate() { + const candidate = deriveCandidate({ repoRoot }); return { candidateId: candidate.id, candidateContext: candidate.context }; + } }); +} + +/** Resume constructs exclusively from the immutable per-Run record; it never rebinds source or policy. */ +export function createStoredProductionRunRunner({ repoRoot, store, runId, startAgent, runCheck, agentTimeoutMs = 0 }) { + if (!store?.readAuthority) fail("sealed production authority is unavailable"); + const authority = store.readAuthority(runId), current = store.readCurrentRun?.(authority.itemRef); + if (!current || current.runId !== runId || current.assignmentId !== authority.assignmentId) fail("Run is superseded and cannot launch", "ELOOP_RUN_SUPERSEDED"); + return createProductionRunRunner({ repoRoot, store, runId, authority, startAgent, runCheck, agentTimeoutMs }); +} diff --git a/src/loops/run/binder.test.mjs b/src/loops/run/binder.test.mjs new file mode 100644 index 00000000..55e720c9 --- /dev/null +++ b/src/loops/run/binder.test.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindRunCreation, createProductionRun, createProductionRunRunner, createStoredProductionRunRunner, revalidatePreparedBinding } from "./binder.mjs"; +import { loadBoundPolicy } from "./run-artifacts.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { ownerClaimId } from "./run-claim.mjs"; +import { runStore } from "./run-store.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; + +test("production creation binds direct Stage One profiles without Docker artifacts", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-direct-binder-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const bound = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const policy = loadBoundPolicy(bound.policyBytes).policy; + assert.equal(bound.itemText, "- [ ] L29 | Exercise production authority\n\n"); + assert.deepEqual(policy.routes.map((route) => Object.keys(route)), [ + ["route", "profile", "profileRevision", "executableDigest", "guarantees"], + ["route", "profile", "profileRevision", "executableDigest", "guarantees"], + ]); + assert.equal(policy.routes.some((route) => JSON.stringify(route).toLowerCase().includes("docker")), false); + assert.deepEqual(policy.routes.find((route) => route.route === "review.strong").guarantees, + { freshSession: "enforced", filesystemWriteDeny: "supervised" }); + assert.equal(revalidatePreparedBinding({ repoRoot: repo, bound }), true); +}); + +test("a publication-cut reservation recovers through either exact or ordinary create retry", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-current-cut-"))); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const cut = runStore(repo, { hooks: { beforeRunPublish() { throw new Error("before-run-publish"); } } }); + await assert.rejects(createProductionRun({ repoRoot: repo, store: cut, itemRef: fixtureItemRef, runId: fixtureRunId }), /before-run-publish/u); + assert.equal(cut.list().length, 0); assert.equal(cut.readCurrentRun(fixtureItemRef).runId, fixtureRunId); + const recovered = await createProductionRun({ repoRoot: repo, store: runStore(repo), itemRef: fixtureItemRef, runId: "run:01arz3ndektsv4rrffq69g5faw" }); + assert.equal(recovered.projection.runId, fixtureRunId); assert.equal(runStore(repo).list().length, 1); +}); + +test("production factory drives direct maker-check-reject-repair-approve", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-direct-runner-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const authority = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const graph = loadFrozenRecipe(authority.frozenRecipeBytes).ir, store = runStore(repo); + store.createRun({ runId: fixtureRunId, itemRef: fixtureItemRef, graph }); + const counter = join(directory, "counter"); writeFileSync(counter, "0"); + const oldCounter = process.env.BURNLIST_FAKE_COUNTER, oldOutcomes = process.env.BURNLIST_FAKE_OUTCOMES; + process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,reject,complete,approve"; + t.after(() => { + if (oldCounter === undefined) delete process.env.BURNLIST_FAKE_COUNTER; else process.env.BURNLIST_FAKE_COUNTER = oldCounter; + if (oldOutcomes === undefined) delete process.env.BURNLIST_FAKE_OUTCOMES; else process.env.BURNLIST_FAKE_OUTCOMES = oldOutcomes; + }); + const candidate = `cm1-sha256:${"c".repeat(64)}`; + const runner = createProductionRunRunner({ repoRoot: repo, store, runId: fixtureRunId, authority, + contextFor(invocation) { + return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, + attempt: invocation.attempt, assignmentId: authority.assignmentId, inputCandidate: candidate }), + inputCandidate: candidate, itemText: "- [ ] L29 | Exercise production authority\n", + candidateContext: "candidate-summary@1\n", reviewerEvidence: ["repo-verify:pass"] }; + }, + runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, + timedOut: false, truncated: false }, evidence: Buffer.from("pass") }), + agentTimeoutMs: 2_000 }); + const completed = await runner.run(); + assert.equal(completed.projection.state, "converged", JSON.stringify(completed.execution)); + assert.equal(completed.execution.attempts.implement, 2); +}); + +test("executable reviewer escalation and malformed or stale finals fail closed", async (t) => { + for (const [name, outcomes, mode, expected] of [ + ["escalate", "complete,escalate", "", "needs-human"], + ["malformed", "complete", "malformed", "failed"], + ["stale", "complete", "stale", "failed"], + ]) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), `burnlist-direct-${name}-`))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const authority = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const store = runStore(repo); store.createRun({ runId: fixtureRunId, itemRef: fixtureItemRef, + graph: loadFrozenRecipe(authority.frozenRecipeBytes).ir }); + const counter = join(directory, "counter"); writeFileSync(counter, "0"); + const previous = [process.env.BURNLIST_FAKE_COUNTER, process.env.BURNLIST_FAKE_OUTCOMES, process.env.BURNLIST_FAKE_FINAL_MODE]; + process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = outcomes; + if (mode) process.env.BURNLIST_FAKE_FINAL_MODE = mode; else delete process.env.BURNLIST_FAKE_FINAL_MODE; + try { + const candidate = `cm1-sha256:${"d".repeat(64)}`; + const runner = createProductionRunRunner({ repoRoot: repo, store, runId: fixtureRunId, authority, + contextFor(invocation) { return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, + attempt: invocation.attempt, assignmentId: authority.assignmentId, inputCandidate: candidate }), + inputCandidate: candidate, candidateContext: "candidate-summary@1\n", reviewerEvidence: ["repo-verify:pass"] }; }, + runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, + timedOut: false, truncated: false }, evidence: Buffer.from("pass") }), agentTimeoutMs: 2_000 }); + assert.equal((await runner.run()).projection.state, expected, name); + } finally { + for (const [key, value] of [["BURNLIST_FAKE_COUNTER", previous[0]], ["BURNLIST_FAKE_OUTCOMES", previous[1]], + ["BURNLIST_FAKE_FINAL_MODE", previous[2]]]) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + } + } +}); + +test("stored production repair binds a fresh repository candidate and gives each reviewer its matching check evidence", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-candidate-repair-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + const created = await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const outcomes = ["complete", "reject", "complete", "approve"], reviewerPrompts = []; let writes = 0; + const startAgent = ({ prompt }) => { + const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); + const outcome = outcomes.shift(); + if (values.node === "implement") writeFileSync(join(repo, "candidate-state.txt"), `maker-write-${++writes}\n`); + if (values.node === "review") reviewerPrompts.push(prompt); + const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), + claimId: values.claim, invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, + policyRevision: values.policy, inputCandidate: values.candidate, outcome, summary: `fake ${outcome}` }; + return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; + }; + const runner = createStoredProductionRunRunner({ repoRoot: repo, store, runId: created.projection.runId, startAgent, + runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("pass") }) }); + assert.equal((await runner.run()).projection.state, "converged"); + const candidates = store.read(fixtureRunId).journal.filter((record) => record.value.type === "candidate-bound").map((record) => record.value.payload.candidateId); + assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1]); + assert.equal(reviewerPrompts.length, 2); + for (const [index, prompt] of reviewerPrompts.entries()) { + assert.match(prompt, new RegExp(`candidate=${candidates[index]}`, "u")); + assert.match(prompt, new RegExp(`trusted-check candidate=${candidates[index]} summary=repository check pass`, "u")); + } +}); + +test("stored production launch rejects changed profile authority before the agent can spawn", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-launch-drift-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo, binary } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + const created = await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + writeFileSync(binary, `${writeFileSync.toString()}\n`); + let spawned = false; + const runner = createStoredProductionRunRunner({ repoRoot: repo, store, runId: created.projection.runId, + startAgent() { spawned = true; throw new Error("must not spawn"); } }); + assert.equal((await runner.run()).projection.state, "failed"); + assert.equal(spawned, false); +}); +test("production replay normalizes item-mismatched, malformed, or missing required authority to EAUTHORITY", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-authority-delete-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + assert.equal(store.read(fixtureRunId).journal[0].value.payload.authorityRequired, true); + const path = store.paths.authorityPath(fixtureRunId), bytes = readFileSync(path); + const mismatched = JSON.parse(bytes); + mismatched.itemRef = "item:260722-001#OTHER"; + writeFileSync(path, `${JSON.stringify(mismatched)}\n`); + assert.throws(() => runStore(repo).read(fixtureRunId), { code: "EAUTHORITY" }); + writeFileSync(path, "{\n"); + assert.throws(() => runStore(repo).read(fixtureRunId), { code: "EAUTHORITY" }); + writeFileSync(path, bytes, { mode: 0o600 }); + rmSync(path); + assert.throws(() => runStore(repo).read(fixtureRunId), { code: "EAUTHORITY" }); +}); + +test("over-limit and unreadable post-maker candidates replay as closed failures without a lease", async (t) => { + for (const mode of ["over-limit", "unreadable"]) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), `burnlist-candidate-${mode}-`))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const startAgent = ({ prompt }) => { + const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); + if (mode === "over-limit") writeFileSync(join(repo, "oversized-candidate.bin"), Buffer.alloc(65_537)); + else symlinkSync(join(repo, "src"), join(repo, "unreadable-candidate")); + const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), + claimId: values.claim, invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, + policyRevision: values.policy, inputCandidate: values.candidate, outcome: "complete", summary: "maker complete" }; + return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", + events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; + }; + const final = await createStoredProductionRunRunner({ repoRoot: repo, store, runId: fixtureRunId, startAgent }).run(); + assert.equal(final.projection.state, "failed", mode); + assert.equal(final.projection.leaseHeld, false, mode); + const replayed = runStore(repo).read(fixtureRunId); + assert.equal(replayed.projection.state, "failed", mode); + assert.equal(replayed.projection.leaseHeld, false, mode); + assert.match(replayed.execution.system.summary, mode === "over-limit" ? /too large/u : /symbolic/u); + } +}); diff --git a/src/loops/run/budgets.mjs b/src/loops/run/budgets.mjs new file mode 100644 index 00000000..4e223867 --- /dev/null +++ b/src/loops/run/budgets.mjs @@ -0,0 +1,28 @@ +const keys = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; +const exact = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const fail = (message) => { throw Object.assign(new Error(`Run budgets: ${message}`), { code: "EBUDGET" }); }; +export function validateBudget(value) { if (!exact(value) || !keys.every((key) => Number.isSafeInteger(value[key]) && value[key] > 0)) fail("invalid limits"); return Object.freeze({ ...value }); } +export function foldBudgets({ records, graph }) { + const budget = validateBudget(graph.budget), nodes = new Map(graph.nodes.map((node) => [node.id, node])), counters = { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }; + const visits = {}; let elapsedMilliseconds = 0, previous = records[0]?.value?.at; + for (const record of records) { + if (!Number.isSafeInteger(record.value.at) || record.value.at < previous) fail("clock regressed"); elapsedMilliseconds += record.value.at - previous; previous = record.value.at; + const { type, payload } = record.value; + if (type === "node-started") { const node = nodes.get(payload.nodeId); if (node.kind === "agent") { counters.agentRuns += 1; if (node.mode === "task") counters.rounds += 1; } else if (node.kind === "check") counters.checkRuns += 1; } + if (type === "edge-taken") { counters.transitions += 1; const key = `${payload.from}\0${payload.on}`, edge = graph.edges.find((item) => item.from === payload.from && item.on === payload.on); visits[key] = (visits[key] ?? 0) + 1; if (edge?.maxVisits !== null && visits[key] > edge.maxVisits) fail("edge visit exceeds limit"); } + if (type === "invocation-result") counters.outputBytes += payload.outputBytes; + } + if (counters.rounds > budget.maxRounds || counters.agentRuns > budget.maxAgentRuns || counters.checkRuns > budget.maxCheckRuns || counters.transitions > budget.maxTransitions || counters.outputBytes > budget.maxOutputBytes) fail("inclusive limit exceeded"); + return Object.freeze({ counters: Object.freeze(counters), visits: Object.freeze(visits), elapsedMilliseconds, timeExceeded: elapsedMilliseconds > budget.maxMinutes * 60_000, journal: Object.freeze({ maximum: MAX_JOURNAL_RECORDS, used: records.length, remaining: MAX_JOURNAL_RECORDS - records.length }) }); +} +export function budgetReason({ folded, graph, node = null, edge = null, outputBytes = 0 }) { + const b = validateBudget(graph.budget), c = folded.counters; + if (folded.elapsedMilliseconds >= b.maxMinutes * 60_000) return "minutes"; + if (edge && (c.transitions >= b.maxTransitions || edge.maxVisits !== null && (folded.visits[`${edge.from}\0${edge.on}`] ?? 0) >= edge.maxVisits)) return "transitions"; + if (node?.kind === "agent" && c.agentRuns >= b.maxAgentRuns) return "agent-runs"; + if (node?.kind === "agent" && node.mode === "task" && c.rounds >= b.maxRounds) return "rounds"; + if (node?.kind === "check" && c.checkRuns >= b.maxCheckRuns) return "check-runs"; + if (!Number.isSafeInteger(outputBytes) || outputBytes < 0 || c.outputBytes + outputBytes > b.maxOutputBytes) return "output-bytes"; + return null; +} +import { MAX_JOURNAL_RECORDS } from "./run-journal.mjs"; diff --git a/src/loops/run/budgets.test.mjs b/src/loops/run/budgets.test.mjs new file mode 100644 index 00000000..ab703e09 --- /dev/null +++ b/src/loops/run/budgets.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { budgetReason, foldBudgets } from "./budgets.mjs"; +import { createJournalRecord } from "./run-journal.mjs"; +import { created, testGraph } from "./m2-test-fixtures.mjs"; + +const record = (sequence, prior, type, payload, at = sequence) => createJournalRecord({ sequence, prevDigest: prior?.digest ?? null, at, type, payload }); +test("fold enforces inclusive counters, retries, visits, output, and time", () => { + const one = record(1, null, "run-created", created(), 0), two = record(2, one, "node-started", { nodeId: "implement", attempt: 1 }), three = record(3, two, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }), four = record(4, three, "invocation-result", { invocationId: "a".repeat(32), kind: "complete", summary: "ok", outputBytes: 1, candidateId: null }); + const folded = foldBudgets({ records: [one, two, three, four], graph: testGraph }); assert.equal(folded.counters.agentRuns, 1); assert.equal(budgetReason({ folded, graph: testGraph, node: testGraph.nodes.find((node) => node.id === "implement") }), null); + const elapsed = foldBudgets({ records: [one, record(2, one, "state-changed", { from: "prepared", to: "running", cause: "control" }, testGraph.budget.maxMinutes * 60000)], graph: testGraph }); assert.equal(budgetReason({ folded: elapsed, graph: testGraph }), "minutes"); + const visits = [one]; + for (let index = 1; index <= 4; index += 1) visits.push(record(index + 1, visits.at(-1), "edge-taken", { from: "review", on: "reject", to: "implement" })); + assert.throws(() => foldBudgets({ records: visits, graph: testGraph }), /visit exceeds/u); +}); diff --git a/src/loops/run/candidate.mjs b/src/loops/run/candidate.mjs new file mode 100644 index 00000000..35ca8ea9 --- /dev/null +++ b/src/loops/run/candidate.mjs @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { prefixed } from "../dsl/hash.mjs"; + +const EXCLUDED = new Set([".git", ".local", "node_modules"]); +const MAX_FILES = 256, MAX_FILE_BYTES = 65_536, MAX_TOTAL_BYTES = 1_048_576; +const fail = (message) => { throw Object.assign(new Error(`Loop candidate: ${message}`), { code: "ECANDIDATE" }); }; +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); + +function fileRecord(root, path) { + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_FILE_BYTES) fail(`candidate file is unsafe or too large: ${relative(root, path)}`); + const bytes = readFileSync(path), after = lstatSync(path); + if (!after.isFile() || after.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino + || before.size !== after.size || before.mtimeMs !== after.mtimeMs || !bytes.length && before.size) + fail(`candidate file changed while reading: ${relative(root, path)}`); + return `${relative(root, path)}\t${bytes.length}\t${sha256(bytes)}`; +} + +/** A bounded, git-free repository manifest. This is detected-at-boundaries, not a hostile-writer claim. */ +export function deriveCandidate({ repoRoot }) { + const root = resolve(repoRoot), rootBefore = lstatSync(root); + if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) fail("repository root is unsafe"); + const files = [], pending = [root]; let total = 0; + while (pending.length) { + const directory = pending.pop(), entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (directory === root && EXCLUDED.has(entry.name)) continue; + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) fail(`candidate path is symbolic: ${relative(root, path)}`); + if (entry.isDirectory()) { pending.push(path); continue; } + if (!entry.isFile()) continue; + const record = fileRecord(root, path), size = Number(record.split("\t")[1]); + total += size; + if (++files.length > MAX_FILES || total > MAX_TOTAL_BYTES) fail("candidate manifest exceeds bounds"); + files.push(record); + } + } + files.sort(); + const rootAfter = lstatSync(root); + if (!rootAfter.isDirectory() || rootAfter.isSymbolicLink() || rootBefore.dev !== rootAfter.dev || rootBefore.ino !== rootAfter.ino) fail("repository root changed while capturing candidate"); + const manifest = `candidate-manifest@1\n${files.join("\n")}\n`; + const id = prefixed("cm1-sha256:", "stage1-repository-candidate-v1", [Buffer.from(manifest)]); + return Object.freeze({ id, context: `candidate-summary@1\ncandidate=${id}\nfiles=${files.length}\n${files.join("\n")}\n` }); +} diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs new file mode 100644 index 00000000..479488d3 --- /dev/null +++ b/src/loops/run/controller.mjs @@ -0,0 +1,49 @@ +import { presentRun } from "./read-projection.mjs"; +import { isRunRef } from "./run-ref.mjs"; + +const fail = (message, code = "ELOOP_CONTROL") => { throw Object.assign(new Error(`Loop control: ${message}`), { code }); }; +const stable = (value) => `${JSON.stringify(value)}\n`; + +/** Small foreground-only control boundary. It owns no daemon or recovery policy. */ +export function createLoopController({ store, runnerFor }) { + if (!store?.read || !store?.list || !store?.acquireLease || !store?.terminalize) fail("invalid controller input"); + const check = (runId) => { if (!isRunRef(runId)) fail("invalid RunRef"); return runId; }; + const read = (runId) => store.read(check(runId)); + const inspect = (runId) => Object.freeze(presentRun(read(runId))); + // Status is a compact public projection, never the internal fold object. + // This keeps frozen Loop identity and journal timestamps available to CLI + // users without exposing dispatch authority or invocation internals. + const status = (runId) => Object.freeze({ ...presentRun(read(runId)), schema: "burnlist-loop-status@1" }); + const list = () => Object.freeze(store.list().map((run) => ({ schema: "burnlist-loop-status@1", ...run }))); + function idleLease(runId) { + const current = read(runId); + if (current.execution.terminal) fail("Run is terminal", "ETERMINAL"); + if (current.execution.lease) fail("Run has an active foreground owner", "ELEASED"); + return store.acquireLease(runId).lease; + } + function pause(runId) { + const lease = idleLease(runId), current = read(runId); + if (current.execution.invocation && !current.execution.result) fail("Run has an active invocation", "EACTIVE"); + store.append(runId, lease, "state-changed", { from: "running", to: "paused", cause: "control" }); + store.releaseLease(runId, lease); return inspect(runId); + } + function stop(runId) { + const lease = idleLease(runId); + return presentRun(store.terminalize(runId, lease, "cancelled", "control")); + } + async function run(runId) { + check(runId); if (typeof runnerFor !== "function") fail("foreground runner is unavailable", "ERUNNER_UNAVAILABLE"); + const runner = runnerFor(runId); if (!runner?.run) fail("foreground runner is unavailable", "ERUNNER_UNAVAILABLE"); + return presentRun(await runner.run()); + } + /** Recovery is deliberately proof-gated: without an owner proof it cannot take a live lease. */ + function reconcile(runId, recoveryProof = null) { + check(runId); const current = read(runId); + if (current.execution.terminal || !current.execution.lease) return inspect(runId); + if (!current.execution.invocation || !recoveryProof) fail("active owner is not demonstrably lost", "ELOST_PROOF"); + store.recoverLease(runId, recoveryProof); + const lease = idleLease(runId); + return presentRun(store.terminalize(runId, lease, "lost", "reconciled lost invocation")); + } + return Object.freeze({ list, inspect, status, pause, stop, run, reconcile, render: stable }); +} diff --git a/src/loops/run/controller.test.mjs b/src/loops/run/controller.test.mjs new file mode 100644 index 00000000..47d710f8 --- /dev/null +++ b/src/loops/run/controller.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createLoopController } from "./controller.mjs"; +import { testGraph, testRunId } from "./m2-test-fixtures.mjs"; +import { createRunRunner } from "./runner.mjs"; +import { runStore } from "./run-store.mjs"; + +function fixture(t) { + const root = mkdtempSync(join(os.tmpdir(), "m6-controller-")); t.after(() => rmSync(root, { recursive: true, force: true })); + let time = 0; const store = runStore(root, { clock: () => time++ }); + store.createRun({ runId: testRunId, itemRef: "item:260722-001#M6", graph: testGraph }); + return { root, store, controller: createLoopController({ store, runnerFor: (runId) => createRunRunner({ store, runId, invoke: async ({ nodeId }) => ({ kind: nodeId === "implement" ? "complete" : nodeId === "verify" ? "pass" : "approve", summary: "ok", outputBytes: 0 }) }) }) }; +} + +test("read commands are byte-stable and do not create a lease", (t) => { + const { controller, store } = fixture(t); + assert.equal(controller.render(controller.status(testRunId)), controller.render(controller.status(testRunId))); + assert.equal(controller.render(controller.inspect(testRunId)), controller.render(controller.inspect(testRunId))); + assert.equal(store.read(testRunId).projection.leaseHeld, false); +}); + +test("pause is resumable, stop is terminal, and an owner fences competing controls", async (t) => { + const { controller, store } = fixture(t); + assert.equal(controller.pause(testRunId).state, "paused"); + assert.equal((await controller.run(testRunId)).state, "converged"); + assert.throws(() => controller.stop(testRunId), { code: "ETERMINAL" }); + const second = fixture(t), owner = second.store.acquireLease(testRunId); + assert.throws(() => second.controller.pause(testRunId), { code: "ELEASED" }); + second.store.releaseLease(testRunId, owner.lease); +}); + +test("reconcile requires a fenced lost invocation and cannot be repeated", (t) => { + const { controller, store } = fixture(t), acquired = store.acquireLease(testRunId); + store.append(testRunId, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 }); + store.append(testRunId, acquired.lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); + assert.throws(() => controller.reconcile(testRunId), { code: "ELOST_PROOF" }); + assert.equal(controller.reconcile(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }).state, "needs-human"); + assert.equal(controller.reconcile(testRunId).state, "needs-human"); +}); diff --git a/src/loops/run/current-authority.mjs b/src/loops/run/current-authority.mjs new file mode 100644 index 00000000..4b94bfd1 --- /dev/null +++ b/src/loops/run/current-authority.mjs @@ -0,0 +1,56 @@ +import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { isRunRef } from "./run-ref.mjs"; + +const MAX_BYTES = 65_536, MAX_ITEMS = 128; +const ITEM = /^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const fail = (message) => { throw Object.assign(new Error(`Current Run authority: ${message}`), { code: "ECURRENT" }); }; +const same = (left, right) => left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mode === right.mode; + +function ancestors(root, base) { + const canonicalRoot = resolve(root), canonicalBase = resolve(base), tail = relative(canonicalRoot, canonicalBase); + if (tail === ".." || tail.startsWith(`..${sep}`)) fail("authority root escapes repository"); + const paths = [canonicalRoot]; let current = canonicalRoot; + for (const part of tail ? tail.split(sep) : []) { current = join(current, part); paths.push(current); } + return paths.map((path) => { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink()) fail("authority ancestor is unsafe"); return { path, dev: stat.dev, ino: stat.ino }; }); +} +function assertAncestors(values) { + for (const expected of values) { const stat = lstatSync(expected.path); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== expected.dev || stat.ino !== expected.ino) fail("authority ancestor changed"); } +} +function parse(bytes) { + let value; try { value = JSON.parse(bytes); } catch { fail("authority is not JSON"); } + if (!value || Object.keys(value).length !== 2 || value.schema !== "burnlist-loop-current-runs@1" || !Array.isArray(value.items) || value.items.length > MAX_ITEMS || !Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail("authority is not canonical"); + const items = value.items.map((entry) => { + if (!entry || Object.keys(entry).length !== 3 || !ITEM.test(entry.itemRef) || !isRunRef(entry.runId) || !ASSIGNMENT.test(entry.assignmentId)) fail("authority item is invalid"); + return Object.freeze({ itemRef: entry.itemRef, runId: entry.runId, assignmentId: entry.assignmentId }); + }); + if (new Set(items.map((item) => item.itemRef)).size !== items.length || items.some((item, index) => index && items[index - 1].itemRef >= item.itemRef)) fail("authority items are unordered or duplicate"); + return Object.freeze(items); +} + +export function currentRunAuthority({ root, base, random }) { + const target = join(base, "current-runs.json"); + function read() { + const anchored = ancestors(root, base); let fd; + try { + let leaf; try { leaf = lstatSync(target); } catch (error) { if (error?.code === "ENOENT") return Object.freeze([]); throw error; } + if (!leaf.isFile() || leaf.isSymbolicLink() || (leaf.mode & 0o777) !== 0o600 || leaf.size < 2 || leaf.size > MAX_BYTES) fail("authority file is unsafe"); + fd = openSync(target, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); const before = fstatSync(fd); + if (!before.isFile() || !same(leaf, before) || (before.mode & 0o777) !== 0o600 || before.size > MAX_BYTES) fail("authority changed while opening"); + const bytes = Buffer.alloc(before.size); if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("authority changed while reading"); + const after = fstatSync(fd), linked = lstatSync(target); if (!same(before, after) || !same(before, linked) || linked.isSymbolicLink()) fail("authority changed while reading"); + assertAncestors(anchored); return parse(bytes); + } finally { if (fd !== undefined) closeSync(fd); } + } + function write(items) { + const anchored = ancestors(root, base), ordered = [...items].sort((a, b) => a.itemRef.localeCompare(b.itemRef)); + const checked = parse(Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-current-runs@1", items: ordered })}\n`)); + const bytes = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-current-runs@1", items: checked })}\n`), temporary = join(base, `.current-runs.${random(8).toString("hex")}.tmp`); let fd; + try { + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; + assertAncestors(anchored); renameSync(temporary, target); const directory = openSync(base, constants.O_RDONLY); try { fsyncSync(directory); } finally { closeSync(directory); } + } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } + return Object.freeze({ read, write, target }); +} diff --git a/src/loops/run/current-authority.test.mjs b/src/loops/run/current-authority.test.mjs new file mode 100644 index 00000000..4ecf901f --- /dev/null +++ b/src/loops/run/current-authority.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { currentRunAuthority } from "./current-authority.mjs"; + +const runId = "run:01arz3ndektsv4rrffq69g5fav", itemRef = "item:260722-001#M8", assignmentId = `as1-sha256:${"a".repeat(64)}`; +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "burnlist-current-authority-")), base = join(root, ".local", "burnlist", "loop", "m2"); + mkdirSync(base, { recursive: true }); t.after(() => rmSync(root, { recursive: true, force: true })); return { root, base, authority: () => currentRunAuthority({ root, base, random: () => Buffer.from("12345678") }) }; +} +function entry() { return { itemRef, runId, assignmentId }; } + +test("current Run authority publishes canonical private records and rejects malformed bounds", (t) => { + const value = fixture(t), authority = value.authority(); authority.write([entry()]); + assert.deepEqual(authority.read(), [entry()]); const target = authority.target; + chmodSync(target, 0o644); assert.throws(() => authority.read(), /unsafe/u); chmodSync(target, 0o600); + writeFileSync(target, Buffer.alloc(65_537), { mode: 0o600 }); assert.throws(() => authority.read(), /unsafe/u); + writeFileSync(target, "{}\n", { mode: 0o600 }); assert.throws(() => authority.read(), /canonical/u); + writeFileSync(target, `${JSON.stringify({ schema: "burnlist-loop-current-runs@1", items: [entry(), entry()] })}\n`, { mode: 0o600 }); assert.throws(() => authority.read(), /unordered or duplicate/u); +}); + +test("current Run authority rejects leaf and ancestor symlink substitution", (t) => { + const value = fixture(t), authority = value.authority(), outside = join(value.root, "outside"); authority.write([entry()]); mkdirSync(outside); + rmSync(authority.target); symlinkSync(join(outside, "missing"), authority.target); assert.throws(() => authority.read(), /unsafe/u); rmSync(authority.target); + const moved = `${value.base}.moved`; renameSync(value.base, moved); symlinkSync(outside, value.base, "dir"); assert.throws(() => authority.read(), /ancestor is unsafe/u); +}); diff --git a/src/loops/run/launch-authority.test.mjs b/src/loops/run/launch-authority.test.mjs new file mode 100644 index 00000000..cf768af9 --- /dev/null +++ b/src/loops/run/launch-authority.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { chmodSync, closeSync, fstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindRunCreation, captureRunLaunchBinding, holdRunLaunchBinding, launchAuthorityDigest, recheckRunLaunchBinding, releaseRunLaunchBinding } from "./binder.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { loadBoundPolicy } from "./run-artifacts.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; + +async function fixture(t) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-direct-launch-authority-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo, binary } = createProductionRunAuthority(join(directory, "repo")); + const bound = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + return { + repo, binary, + replay: { + projection: { itemRef: bound.itemRef, assignmentId: bound.assignmentId, itemRevision: bound.itemRevision }, + frozenRecipe: loadFrozenRecipe(bound.frozenRecipeBytes), boundPolicy: loadBoundPolicy(bound.policyBytes).policy, policyBytes: bound.policyBytes, + }, + }; +} + +test("direct Stage One launch binding holds every live profile and trusted capability descriptor", async (t) => { + const value = await fixture(t), captured = captureRunLaunchBinding({ repoRoot: value.repo, replay: value.replay }); + assert.deepEqual(captured.evidence.map((entry) => entry.role), [ + "adapter:implementation.standard", "adapter:review.strong", "capability-bin", "capability-catalog", "capability-trust", + "profile:implementation.standard", "profile:review.strong", "route:implementation.standard", "route:review.strong", + ]); + recheckRunLaunchBinding(captured); + const held = holdRunLaunchBinding(captured); + assert.equal(held.length, captured.evidence.length, "each direct launch authority input is sealed"); + releaseRunLaunchBinding(held); + for (const item of held) assert.throws(() => fstatSync(item.sealedDescriptor), { code: "EBADF" }); +}); + +test("direct launch binding detects executable replacement before descriptor hold", async (t) => { + const value = await fixture(t), captured = captureRunLaunchBinding({ repoRoot: value.repo, replay: value.replay }); + writeFileSync(value.binary, `${readFileSync(value.binary, "utf8")}\n// changed\n`); + assert.throws(() => recheckRunLaunchBinding(captured), /changed/u); + assert.throws(() => holdRunLaunchBinding(captured), /changed/u); +}); + +test("direct launch binding rejects a non-executable configured profile binary", async (t) => { + const value = await fixture(t); + chmodSync(value.binary, 0o600); + assert.throws(() => captureRunLaunchBinding({ repoRoot: value.repo, replay: value.replay }), /not executable/u); +}); + +test("release closes every descriptor after an earlier close failure", async (t) => { + const value = await fixture(t), captured = captureRunLaunchBinding({ repoRoot: value.repo, replay: value.replay }), held = holdRunLaunchBinding(captured); + closeSync(held[0].sealedDescriptor); + assert.throws(() => releaseRunLaunchBinding(held), { code: "EBADF" }); + for (const item of held.slice(1)) assert.throws(() => fstatSync(item.sealedDescriptor), { code: "EBADF" }); +}); + +test("launch authority digest commits each direct authority field", () => { + const evidence = [{ role: "adapter:implementation.standard", executable: true, snapshot: { + root: "/", path: "/tool", kind: "file", ancestors: [{ path: "/", identity: { dev: 1, ino: 2, size: 3, mode: 4, mtimeMs: 5, ctimeMs: 6 } }], + identity: { dev: 7, ino: 8, size: 9, mode: 10, mtimeMs: 11, ctimeMs: 12 }, digest: `sha256:${"a".repeat(64)}`, maximum: 13, + } }]; + const baseline = launchAuthorityDigest(evidence); + for (const mutate of [ + (value) => { value[0].role = "profile:implementation.standard"; }, (value) => { value[0].executable = false; }, + (value) => { value[0].snapshot.path = "/other/tool"; }, (value) => { value[0].snapshot.identity.ino = 99; }, + (value) => { value[0].snapshot.digest = `sha256:${"b".repeat(64)}`; }, + ]) { const changed = structuredClone(evidence); mutate(changed); assert.notEqual(launchAuthorityDigest(changed), baseline); } +}); diff --git a/src/loops/run/launch-commit.mjs b/src/loops/run/launch-commit.mjs new file mode 100644 index 00000000..c29a9f6d --- /dev/null +++ b/src/loops/run/launch-commit.mjs @@ -0,0 +1,56 @@ +import { join } from "node:path"; +import { withLock, findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { parseItemRef } from "../assignment/selectors.mjs"; +import { configRoot } from "../config/store.mjs"; +import { prefixed } from "../dsl/hash.mjs"; +import { foldStateMachine, selectRunnableNode } from "./state-machine.mjs"; + +function fail(message) { throw Object.assign(new Error(`Loop launch: ${message}`), { code: "ELOOP_RUNNER" }); } +function invocationId({ claimId, counter }) { + return prefixed("iv1-sha256:", "invocation-v1", [Buffer.from(claimId), Buffer.from(String(counter))]); +} + +/** One closed lifecycle -> config -> Run-journal launch transaction. */ +export function createLaunchCommit({ repoRoot, replayRaw, journalLockPath, appendLocked, withCatalog, capture, recheck, hold, release }) { + return function commitLaunch(runId, input) { + if (!input || Object.keys(input).length !== 2 || typeof input.nodeId !== "string" + || !input.clockSample || typeof input.clockSample !== "object") fail("invalid launch request"); + const initial = replayRaw(runId); + const item = parseItemRef(initial.projection.itemRef); + const plan = findBurnlistDir(repoRoot, item.burnlistId); + return withLock(plan.dir, () => withDirectoryLock({ + lockPath: join(configRoot(repoRoot), ".config.lock"), reclaimLiveAfterAge: false, + errorFactory: () => fail("Loop configuration is locked"), fn: () => withCatalog(() => withDirectoryLock({ + lockPath: journalLockPath(runId), reclaimLiveAfterAge: false, + errorFactory: () => fail("Run journal is locked"), fn() { + let current = replayRaw(runId); const captured = capture({ repoRoot, replay: current }); + appendLocked(runId, { type: "clock-sampled", payload: input.clockSample, + expectedSequence: current.projection.sequence + 1, expectedPrevDigest: current.projection.journalDigest }); + current = replayRaw(runId); + const state = foldStateMachine({ ir: current.frozenRecipe.ir, records: current.journal }); + const selected = selectRunnableNode({ ir: current.frozenRecipe.ir, records: current.journal }); + if (selected.kind === "exhausted") return Object.freeze({ kind: "exhausted", reason: selected.reason }); + if (selected.kind !== "spawn" || selected.node.id !== input.nodeId || !state.owner) + fail("spawn has no committed current entry"); + const counter = selected.node.kind === "agent" + ? state.budget.counters.agentRuns + 1 : state.budget.counters.checkRuns + 1; + const id = invocationId({ claimId: state.owner.claimId, counter }); + let held = []; + try { + const committed = appendLocked(runId, { type: "spawn-intent", payload: { + schema: "burnlist-loop-spawn-intent@1", nodeId: input.nodeId, attempt: selected.attempt, + claimId: state.owner.claimId, invocationId: id, launchAuthorityDigest: captured.authorityDigest, + }, expectedSequence: current.projection.sequence + 1, expectedPrevDigest: current.projection.journalDigest, + hooks: { beforePublish() { + // This is a detected-at-publication boundary, not an OS execution claim. + // L9 must compare launchAuthorityDigest while binding the real process. + recheck(captured); held = hold(captured); recheck(captured); + } } }); + return Object.freeze({ kind: "spawned", invocationId: id, committed }); + } finally { release(held); } + }, + })), + })); + }; +} diff --git a/src/loops/run/lifecycle.mjs b/src/loops/run/lifecycle.mjs new file mode 100644 index 00000000..9ce4c34d --- /dev/null +++ b/src/loops/run/lifecycle.mjs @@ -0,0 +1,76 @@ +const TERMINAL = new Set(["completed", "failed", "stopped", "budget-exhausted", "needs-human"]); +const NONTERMINAL = new Set(["prepared", "running", "pausing", "paused", "quarantined", "converged-pending-completion", "completion-needs-human"]); +const TRANSITIONS = Object.freeze({ + prepared: new Set(["running", "stopped", "failed", "quarantined"]), + running: new Set(["pausing", "converged-pending-completion", "failed", "stopped", "budget-exhausted", "needs-human", "quarantined"]), + pausing: new Set(["paused", "quarantined"]), + paused: new Set(["running", "stopped", "quarantined"]), + quarantined: new Set(["failed", "stopped", "paused", "budget-exhausted", "needs-human", "converged-pending-completion"]), + "converged-pending-completion": new Set(["completed", "completion-needs-human"]), + "completion-needs-human": new Set(["completed", "needs-human"]), +}); +const QUARANTINE_TARGETS = Object.freeze({ + prepared: new Set(["failed", "stopped"]), + running: new Set(["failed", "stopped", "budget-exhausted", "needs-human", "converged-pending-completion"]), + pausing: new Set(["paused"]), + paused: new Set(["paused", "stopped"]), +}); + +function fail(message) { throw Object.assign(new Error(`Loop lifecycle: ${message}`), { code: "ELOOP_STATE" }); } +function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +const ANOMALY = new Set(["clock-monotonic-regression", "clock-wall-regression"]); + +export function isTerminalState(state) { return TERMINAL.has(state); } +export function isNonterminalState(state) { return NONTERMINAL.has(state); } + +export function validateTransition({ from, to, settleTo = null }) { + if (!NONTERMINAL.has(from) || !TRANSITIONS[from]?.has(to)) fail("state transition is not allowed"); + if (to === "quarantined") { + if (!QUARANTINE_TARGETS[from]?.has(settleTo)) fail("quarantine settle target is not allowed from source state"); + } else if (settleTo !== null) fail("only quarantine carries a settle target"); + return Object.freeze({ from, to, settleTo }); +} + +export function stateTransitionPayload(value) { + if (!exact(value, ["schema", "from", "to", "settleTo"]) || value.schema !== "burnlist-loop-run-state-transition@1") fail("invalid state transition payload"); + return Object.freeze({ schema: value.schema, ...validateTransition(value) }); +} + +export function legacyTransitionPayload(value) { + if (!exact(value, ["schema", "from", "to"]) || value.schema !== "burnlist-loop-state-transition@1") fail("invalid legacy state transition"); + const transition = validateTransition({ from: value.from, to: value.to, settleTo: null }); + if (transition.to === "quarantined") fail("legacy transition cannot represent quarantine"); + return transition; +} + +export function clockAnomalyTransitionPayload(value) { + if (!exact(value, ["schema", "from", "to", "code", "sequence"]) + || value.schema !== "burnlist-loop-clock-anomaly-transition@1" || !NONTERMINAL.has(value.from) + || value.to !== "needs-human" || !ANOMALY.has(value.code) + || !Number.isSafeInteger(value.sequence) || value.sequence < 1) fail("invalid clock anomaly transition"); + return Object.freeze({ ...value }); +} + +/** The sole lifecycle fold used by store, execution, deadlines, and recovery. */ +export function foldLifecycle(records) { + if (!Array.isArray(records) || records[0]?.value?.type !== "run-created") fail("journal lacks Run creation"); + let state = "prepared", settleTo = null; + const stateAfter = []; + for (const [index, record] of records.entries()) { + if (index && ["run-state-transition", "state-transition", "clock-anomaly-transition"].includes(record.value.type)) { + if (record.value.type === "clock-anomaly-transition") { + const transition = clockAnomalyTransitionPayload(record.value.payload); + if (transition.from !== state) fail("clock anomaly transition predecessor does not match replay"); + state = transition.to; settleTo = null; stateAfter.push(state); continue; + } + const transition = record.value.type === "run-state-transition" + ? stateTransitionPayload(record.value.payload) : legacyTransitionPayload(record.value.payload); + if (transition.from !== state) fail("state transition predecessor does not match replay"); + if (state === "quarantined" && transition.to !== settleTo) fail("quarantine may settle only to its immutable target"); + state = transition.to; + settleTo = state === "quarantined" ? transition.settleTo : null; + } + stateAfter.push(state); + } + return Object.freeze({ state, settleTo, stateAfter: Object.freeze(stateAfter) }); +} diff --git a/src/loops/run/m2-test-fixtures.mjs b/src/loops/run/m2-test-fixtures.mjs new file mode 100644 index 00000000..5fff7c6c --- /dev/null +++ b/src/loops/run/m2-test-fixtures.mjs @@ -0,0 +1,9 @@ +export const testRunId = "run:01arz3ndektsv4rrffq69g5fav"; +export const testGraph = Object.freeze({ schema: "burnlist-loop-ir@1", compiler: "burnlist-loop-compiler@1", id: "review", declaredVersion: "0.1.0", entry: "implement", budget: { maxRounds: 3, maxMinutes: 60, maxAgentRuns: 6, maxCheckRuns: 3, maxTransitions: 16, maxOutputBytes: 262144 }, nodes: [ + { kind: "agent", id: "implement", mode: "task", role: "maker", route: "implementation.standard", authority: "write", instructions: "implement", independentFrom: null, requires: [] }, + { kind: "terminal", id: "completed", state: "converged" }, { kind: "gate", id: "converged", gateKind: "convergence", requires: ["verify", "review"] }, { kind: "terminal", id: "exhausted", state: "budget-exhausted" }, { kind: "terminal", id: "failed", state: "failed" }, { kind: "terminal", id: "needs-human", state: "needs-human" }, + { kind: "agent", id: "review", mode: "review", role: "reviewer", route: "review.strong", authority: "read", instructions: "review", independentFrom: "implement", requires: ["fresh-session:enforced", "filesystem-write-deny:supervised"] }, { kind: "terminal", id: "stopped", state: "stopped" }, { kind: "check", id: "verify", capability: "repo-verify" }, +], failurePolicy: { error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "exhausted" }, edges: [ + { from: "implement", on: "complete", to: "verify", maxVisits: null }, { from: "converged", on: "pass", to: "completed", maxVisits: null }, { from: "converged", on: "fail", to: "needs-human", maxVisits: null }, { from: "review", on: "approve", to: "converged", maxVisits: null }, { from: "review", on: "reject", to: "implement", maxVisits: 3 }, { from: "review", on: "escalate", to: "needs-human", maxVisits: null }, { from: "verify", on: "pass", to: "review", maxVisits: null }, { from: "verify", on: "fail", to: "implement", maxVisits: 3 }, +], instructions: [{ id: "implement", digest: "sha256:8d1db5c7cbc11075fccc565180374e1929a8cf22683cbca3dde9b77ef667c0a8", byteLength: 114 }, { id: "review", digest: "sha256:53ca65be9491688119b5cbf2443d0b9aff003764db9e91657ec54dbae12d76aa", byteLength: 119 }] }); +export function created(graph = testGraph) { return { schema: "burnlist-loop-m2-run@1", runId: testRunId, itemRef: "item:260722-001#M2", graph, authorityRequired: false }; } diff --git a/src/loops/run/operation.mjs b/src/loops/run/operation.mjs new file mode 100644 index 00000000..d002580d --- /dev/null +++ b/src/loops/run/operation.mjs @@ -0,0 +1,92 @@ +import { prefixed } from "../dsl/hash.mjs"; +import { canonicalPayload } from "./run-codec.mjs"; +import { clockAnomalyTransitionPayload, stateTransitionPayload } from "./lifecycle.mjs"; + +const OPERATION = /^op1-sha256:[a-f0-9]{64}$/u; +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const STEP_TYPES = new Set(["edge-traversed", "owner-claim-released", "graph-outcome", + "system-outcome", "run-state-transition", "clock-anomaly-transition"]); +const NODE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const INVOCATION = /^iv1-sha256:[a-f0-9]{64}$/u; +const TERMINAL = new Set(["converged", "needs-human", "failed", "stopped", "budget-exhausted"]); +const STATE = new Set(["running", "paused", "converged-pending-completion", "completion-needs-human", ...TERMINAL]); +const SYSTEM = new Set(["error", "timeout", "cancelled", "lost", "exhausted"]); +const OUTCOME = new Set(["pass", "fail", "reject", "approve", "complete", "escalate", + ...SYSTEM, "clock-monotonic-regression", "clock-wall-regression"]); +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +function fail(message) { + throw Object.assign(new Error(`Loop operation: ${message}`), { code: "ELOOP_OPERATION" }); +} +function step(value) { + if (!exact(value, ["type", "payload"]) || !STEP_TYPES.has(value.type)) fail("invalid operation step"); + const { type, payload } = value; + if (type === "run-state-transition") stateTransitionPayload(payload); + else if (type === "clock-anomaly-transition") clockAnomalyTransitionPayload(payload); + else if (type === "owner-claim-released") { + if (!exact(payload, ["schema", "runId", "claimId"]) || payload.schema !== "burnlist-loop-owner-claim-release@1" || !CLAIM.test(payload.claimId)) fail("invalid operation release"); + } else if (type === "edge-traversed") { + if (!exact(payload, ["schema", "from", "on", "to", "visit", "claimId", "invocationId"]) || payload.schema !== "burnlist-loop-edge-traversed@1" + || !NODE.test(payload.from) || !NODE.test(payload.to) || !["pass", "fail", "reject", "approve", "complete", "escalate"].includes(payload.on) + || !Number.isSafeInteger(payload.visit) || payload.visit < 1 || payload.visit > 100 || !(payload.claimId === null || CLAIM.test(payload.claimId)) + || !(payload.invocationId === null || INVOCATION.test(payload.invocationId))) fail("invalid operation edge"); + } else if (type === "graph-outcome") { + if (!exact(payload, ["schema", "nodeId", "state"]) || payload.schema !== "burnlist-loop-graph-outcome@1" || !NODE.test(payload.nodeId) || !TERMINAL.has(payload.state)) fail("invalid operation graph outcome"); + } else if (!exact(payload, ["schema", "outcome", "nodeId", "state", "reason"]) + || payload.schema !== "burnlist-loop-system-outcome@1" || !SYSTEM.has(payload.outcome) || !NODE.test(payload.nodeId) + || !TERMINAL.has(payload.state) || typeof payload.reason !== "string" || !payload.reason || payload.reason.length > 512 || /[\0\r\n]/u.test(payload.reason)) fail("invalid operation system outcome"); + return Object.freeze({ type, payload: canonicalPayload(payload) }); +} + +export function operationIntent({ runId, journalDigest, claimId, nodeId, outcome, targetState, steps }) { + if (typeof runId !== "string" || typeof journalDigest !== "string" || !(claimId === null || CLAIM.test(claimId)) + || !NODE.test(nodeId) || !OUTCOME.has(outcome) || !STATE.has(targetState) + || !Array.isArray(steps) || steps.length < 1 || steps.length > 5) fail("invalid operation plan"); + const checked = steps.map(step), plan = { claimId, nodeId, outcome, targetState, steps: checked }; + const operationId = prefixed("op1-sha256:", "run-operation-v1", + [Buffer.from(runId), Buffer.from(journalDigest), Buffer.from(JSON.stringify(canonicalPayload(plan)))]); + return Object.freeze({ schema: "burnlist-loop-operation-intent@1", operationId, ...plan, steps: Object.freeze(checked) }); +} + +export function validateOperationIntent(value) { + if (!exact(value, ["schema", "operationId", "claimId", "nodeId", "outcome", "targetState", "steps"]) + || value.schema !== "burnlist-loop-operation-intent@1" || !OPERATION.test(value.operationId)) fail("invalid operation intent"); + operationIntent({ runId: "validation", journalDigest: "validation", claimId: value.claimId, + nodeId: value.nodeId, outcome: value.outcome, targetState: value.targetState, steps: value.steps }); + return Object.freeze({ ...value, steps: Object.freeze(value.steps.map((step) => Object.freeze(step))) }); +} + +export function operationCompletedPayload(operationId) { + if (!OPERATION.test(operationId)) fail("invalid completed operation"); + return Object.freeze({ schema: "burnlist-loop-operation-completed@1", operationId }); +} + +export function foldOperation(records) { + let pending = null; const runId = records[0]?.value?.payload?.runId; + for (const record of records) { + const { type, payload } = record.value; + if (type === "operation-intent") { + if (pending) fail("operation intent overlaps pending operation"); + const intent = validateOperationIntent(payload); + const expected = operationIntent({ runId, journalDigest: record.value.prevDigest, claimId: intent.claimId, + nodeId: intent.nodeId, outcome: intent.outcome, targetState: intent.targetState, steps: intent.steps }); + if (expected.operationId !== intent.operationId) fail("operation id is not journal-derived"); + pending = { intent, cursor: 0 }; + continue; + } + if (!pending) { + if (type === "operation-completed") fail("operation completion has no intent"); + continue; + } + if (pending.cursor < pending.intent.steps.length) { + const expected = pending.intent.steps[pending.cursor]; + if (type !== expected.type || JSON.stringify(payload) !== JSON.stringify(expected.payload)) fail("operation prefix differs from intent"); + pending.cursor += 1; continue; + } + if (!exact(payload, ["schema", "operationId"]) || type !== "operation-completed" + || payload.schema !== "burnlist-loop-operation-completed@1" + || payload.operationId !== pending.intent.operationId) fail("completed operation prefix lacks exact marker"); + pending = null; + } + return pending && Object.freeze({ intent: pending.intent, cursor: pending.cursor }); +} diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs new file mode 100644 index 00000000..e13c6fc0 --- /dev/null +++ b/src/loops/run/read-projection.mjs @@ -0,0 +1,112 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { readJournal } from "./run-journal.mjs"; +import { foldRun } from "./run-fold.mjs"; +import { isRunRef } from "./run-ref.mjs"; +import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; +import { assignmentStore } from "../assignment/store.mjs"; +import { currentRunAuthority } from "./current-authority.mjs"; +import { runStore } from "./run-store.mjs"; + +const MAX_RUNS = 128; +const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; + +function publicNode(node) { + return { id: node.id, kind: node.kind }; +} + +export function presentRun(replay) { + const records = replay.journal; + let latestResult = null; + const transitions = []; + for (const record of records) { + const { sequence, type, payload } = record.value; + if (type === "invocation-result") latestResult = { + kind: payload.kind, summary: payload.summary, + }; + if (type === "edge-taken") transitions.push({ sequence, from: payload.from, outcome: payload.on, to: payload.to }); + if (type === "state-changed") transitions.push({ sequence, from: payload.from, outcome: payload.cause, to: payload.to }); + } + return Object.freeze({ + schema: "burnlist-loop-read-projection@1", + runId: replay.projection.runId, + itemRef: replay.projection.itemRef, + loopId: replay.loopIdentity?.loopId ?? replay.graph.id, + loopRevision: replay.loopIdentity?.loopRevision ?? null, + createdAt: records[0].value.at, + updatedAt: records.at(-1).value.at, + state: replay.projection.state, + currentNode: replay.projection.currentNode, + attempt: replay.projection.attempt, + cycle: replay.execution.cycle, + latestResult, + latestMaker: replay.projection.latestMaker, + latestCheck: replay.projection.latestCheck, + latestReviewer: replay.projection.latestReviewer, + revision: replay.revision, + budget: { + limits: replay.graph.budget, + counters: replay.execution.budget.counters, + elapsedMilliseconds: replay.execution.budget.elapsedMilliseconds, + journal: replay.execution.budget.journal, + }, + graph: { + entry: replay.graph.entry, + nodes: replay.graph.nodes.map(publicNode), + edges: replay.graph.edges.map(({ from, on, to }) => ({ from, on, to })), + }, + transitions, + }); +} + +/** Read-only bounded discovery. Missing state returns null and never creates directories. */ +export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemId = null, assignmentId = null }) { + let artifact = null; + if (markdown !== null || itemId !== null || assignmentId !== null) { + try { + const metadata = validateAssignedItem(itemRef, locateItemSpan(markdown, itemId)); + if (metadata["Assignment-Id"] !== assignmentId) return null; + artifact = assignmentStore(repoRoot).load(assignmentId); + if (artifact.itemRef !== itemRef + || artifact.assignmentId !== assignmentId + || artifact.assignedItemDigest !== metadata.assignedDigest + || artifact.unassignedItemDigest !== metadata.unassignedDigest + || artifact.executionRevision !== metadata["Execution-Revision"] + || artifact.packageRevision !== metadata["Package-Revision"]) return null; + } catch { + return null; + } + } + const runs = join(resolve(repoRoot), ".local", "burnlist", "loop", "m2", "runs"); + if (!existsSync(runs)) return null; + const base = join(resolve(repoRoot), ".local", "burnlist", "loop", "m2"); + const current = currentRunAuthority({ root: repoRoot, base, random: () => Buffer.alloc(8) }).read() + .find((entry) => entry.itemRef === itemRef) ?? null; + if (current && artifact && current.assignmentId !== artifact.assignmentId) return null; + let entries; + try { entries = readdirSync(runs, { withFileTypes: true }); } catch { fail("Run projection is corrupt", "ECORRUPT"); } + const staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); + const visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); + if (staging.length > MAX_RUNS || visible.length > MAX_RUNS + || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds"); + let selected = null; + for (const entry of visible) { + if (!entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name)) fail("Run projection is corrupt", "ECORRUPT"); + let runId; + try { runId = Buffer.from(entry.name, "hex").toString("utf8"); } catch { fail("Run projection is corrupt", "ECORRUPT"); } + if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) fail("Run projection is corrupt", "ECORRUPT"); + let journal, folded; + try { + journal = readJournal(join(runs, entry.name, "journal")); + folded = foldRun(journal); + } catch { fail("Run projection is corrupt", "ECORRUPT"); } + if (folded.projection.itemRef !== itemRef || current && runId !== current.runId) continue; + if (artifact && JSON.stringify(folded.graph) !== JSON.stringify(artifact.frozen.ir)) continue; + if (selected) fail("Run projection is ambiguous", "EAMBIGUOUS"); + selected = { ...folded, journal }; + } + if (current && !selected) fail("current Run is unavailable", "ECURRENT"); + if (!selected) return null; + selected.loopIdentity = runStore(repoRoot).read(selected.projection.runId).loopIdentity; + return presentRun(selected); +} diff --git a/src/loops/run/reviewer-isolation.test.mjs b/src/loops/run/reviewer-isolation.test.mjs new file mode 100644 index 00000000..f10bdd07 --- /dev/null +++ b/src/loops/run/reviewer-isolation.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindRunCreation } from "./binder.mjs"; +import { loadBoundPolicy } from "./run-artifacts.mjs"; +import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; + +test("reviewer authority is a fresh direct read profile with supervised write denial", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-direct-reviewer-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const bound = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const reviewer = loadBoundPolicy(bound.policyBytes).policy.routes.find((route) => route.route === "review.strong"); + assert.equal(reviewer.profile.authority, "read"); + assert.deepEqual(reviewer.guarantees, { freshSession: "enforced", filesystemWriteDeny: "supervised" }); + assert.equal(Object.hasOwn(reviewer, "controller"), false); + assert.equal(Object.hasOwn(reviewer, "probe"), false); +}); diff --git a/src/loops/run/run-admission.mjs b/src/loops/run/run-admission.mjs new file mode 100644 index 00000000..ccd81e3a --- /dev/null +++ b/src/loops/run/run-admission.mjs @@ -0,0 +1,35 @@ +import { operationCompletedPayload } from "./operation.mjs"; +import { createJournalRecord } from "./run-journal.mjs"; +import { MAX_CATALOG_JOURNAL_BYTES, MAX_RUN_JOURNAL_BYTES, fail } from "./run-codec.mjs"; +import { foldStateMachine } from "./state-machine.mjs"; + +function next(previous, type, payload) { + return createJournalRecord({ schema: "burnlist-loop-journal@1", sequence: previous.value.sequence + 1, + prevDigest: previous.digest, type, artifacts: [], payload }); +} + +export function assertJournalCapacity({ runBytes, catalogBytes, recordBytes, + runLimit = MAX_RUN_JOURNAL_BYTES, catalogLimit = MAX_CATALOG_JOURNAL_BYTES }) { + if (![runBytes, catalogBytes, recordBytes, runLimit, catalogLimit].every((value) => Number.isSafeInteger(value) && value >= 0)) + fail("invalid journal capacity accounting"); + if (runBytes + recordBytes > runLimit || catalogBytes + recordBytes > catalogLimit) + fail("journal publication would exceed replay bounds"); +} + +/** Proves one append and any declared operation continuation before bytes are published. */ +export function admitAppend({ current, type, payload, artifacts, catalogBytes }) { + if (type === "run-created") fail("Run creation cannot be appended"); + const prospective = createJournalRecord({ schema: "burnlist-loop-journal@1", + sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, type, artifacts, payload }); + const runBytes = current.journal.reduce((total, record) => total + record.bytes.length, 0); + assertJournalCapacity({ runBytes, catalogBytes, recordBytes: prospective.bytes.length }); + let records = [...current.journal, prospective]; + if (type === "operation-intent") { + let previous = prospective; + for (const step of payload.steps) { previous = next(previous, step.type, step.payload); records.push(previous); } + previous = next(previous, "operation-completed", operationCompletedPayload(payload.operationId)); records.push(previous); + const result = foldStateMachine({ ir: current.frozenRecipe.ir, records }); + if (result.state !== payload.targetState) fail("operation target state does not match its exact steps"); + } else foldStateMachine({ ir: current.frozenRecipe.ir, records }); + return prospective; +} diff --git a/src/loops/run/run-admission.test.mjs b/src/loops/run/run-admission.test.mjs new file mode 100644 index 00000000..e3ccf18b --- /dev/null +++ b/src/loops/run/run-admission.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertJournalCapacity } from "./run-admission.mjs"; + +test("prospective Run and catalog journal bounds admit the exact boundary only", () => { + assert.doesNotThrow(() => assertJournalCapacity({ + runBytes: 7, catalogBytes: 17, recordBytes: 3, runLimit: 10, catalogLimit: 20, + })); + assert.throws(() => assertJournalCapacity({ + runBytes: 8, catalogBytes: 17, recordBytes: 3, runLimit: 10, catalogLimit: 20, + }), /replay bounds/u); + assert.throws(() => assertJournalCapacity({ + runBytes: 7, catalogBytes: 18, recordBytes: 3, runLimit: 10, catalogLimit: 20, + }), /replay bounds/u); + assert.throws(() => assertJournalCapacity({ + runBytes: -1, catalogBytes: 0, recordBytes: 1, runLimit: 10, catalogLimit: 20, + }), /capacity accounting/u); +}); diff --git a/src/loops/run/run-artifacts.mjs b/src/loops/run/run-artifacts.mjs new file mode 100644 index 00000000..dca2f0a0 --- /dev/null +++ b/src/loops/run/run-artifacts.mjs @@ -0,0 +1,95 @@ +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { validateDispatchAuthority } from "../contracts/agent-result.mjs"; +import { parseBoundedObject } from "../contracts/contract.mjs"; +import { resolveConfiguredStageOneRoutes, validateAgentProfile, agentProfileRevision } from "../agents/profile.mjs"; +import { canonicalCapabilityBytes, canonicalGrantBytes, capabilityRevision, GUARANTEE_LABELS, + validateCapability, validateCapabilityGrants } from "../capabilities/contract.mjs"; + +const PROFILE = /^ap1-sha256:[a-f0-9]{64}$/u; +const RAW = /^sha256:[a-f0-9]{64}$/u; +const RECIPE = /^er1-sha256:[a-f0-9]{64}$/u; +const POLICY_KEYS = ["schema", "recipeRevision", "routes", "capabilities"]; +const ROUTE_KEYS = ["route", "profile", "profileRevision", "executableDigest", "guarantees"]; +const CAPABILITY_KEYS = ["id", "policy", "revision", "policyDigest", "grants", "grantsDigest", "trust", "guarantees"]; + +function fail(message) { throw Object.assign(new Error(`Loop bound policy: ${message}`), { code: "ELOOP_BOUND_POLICY" }); } +function closed(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } +function sortedUnique(values, key) { return values.every((value, index) => index === 0 || Buffer.compare(Buffer.from(values[index - 1][key]), Buffer.from(value[key])) < 0); } +const canonical = (value) => Buffer.from(`${JSON.stringify(value)}\n`); + +export function validateBoundPolicy(value) { + if (!closed(value, POLICY_KEYS) || value.schema !== "burnlist-loop-bound-policy@1" || !RECIPE.test(value.recipeRevision) + || !Array.isArray(value.routes) || value.routes.length !== 2 || !Array.isArray(value.capabilities) || value.capabilities.length !== 1) fail("invalid closed policy"); + const routes = value.routes.map((entry) => { + if (!closed(entry, ROUTE_KEYS) || !["implementation.standard", "review.strong"].includes(entry.route) + || !PROFILE.test(entry.profileRevision) || !RAW.test(entry.executableDigest) + || !closed(entry.guarantees, entry.route === "review.strong" + ? ["freshSession", "filesystemWriteDeny"] : ["freshSession"])) fail("invalid route binding"); + const profile = validateAgentProfile(entry.profile), profileRevision = agentProfileRevision(profile); + if (entry.profileRevision !== profileRevision || entry.guarantees.freshSession !== "enforced" + || entry.route === "review.strong" && entry.guarantees.filesystemWriteDeny !== "supervised") fail("route revision or guarantee mismatch"); + return { route: entry.route, profile, profileRevision, executableDigest: entry.executableDigest, + guarantees: { ...entry.guarantees } }; + }); + if (!sortedUnique(routes, "route")) fail("invalid route set"); + resolveConfiguredStageOneRoutes({ profiles: routes.map((entry) => entry.profile), + routes: Object.fromEntries(routes.map((entry) => [entry.route, entry.profile.id])) }); + const capabilities = value.capabilities.map((entry) => { + if (!closed(entry, CAPABILITY_KEYS) || entry.id !== "repo-verify" || !RAW.test(entry.policyDigest) || !RAW.test(entry.grantsDigest) + || JSON.stringify(entry.guarantees) !== JSON.stringify(GUARANTEE_LABELS)) fail("invalid capability binding"); + const policy = validateCapability(entry.policy), grants = validateCapabilityGrants(entry.grants, policy); + if (policy.id !== entry.id || entry.revision !== capabilityRevision(policy) + || entry.policyDigest !== rawSha256(canonicalCapabilityBytes(policy)) || entry.grantsDigest !== rawSha256(canonicalGrantBytes(grants, policy)) + || !closed(entry.trust, ["schema", "capability", "revision", "policyDigest", "grants", "grantsDigest"]) + || entry.trust.schema !== "burnlist-loop-capability-trust@1" || entry.trust.capability !== entry.id + || entry.trust.revision !== entry.revision || entry.trust.policyDigest !== entry.policyDigest + || entry.trust.grantsDigest !== entry.grantsDigest || JSON.stringify(entry.trust.grants) !== JSON.stringify(grants)) fail("capability policy or trust mismatch"); + return { id: entry.id, policy, revision: entry.revision, policyDigest: entry.policyDigest, grants, + grantsDigest: entry.grantsDigest, trust: { schema: entry.trust.schema, capability: entry.trust.capability, + revision: entry.trust.revision, policyDigest: entry.trust.policyDigest, grants, grantsDigest: entry.trust.grantsDigest }, + guarantees: Object.fromEntries(Object.keys(GUARANTEE_LABELS).map((key) => [key, GUARANTEE_LABELS[key]])) }; + }); + if (!sortedUnique(capabilities, "id") || capabilities[0]?.id !== "repo-verify") fail("invalid capability set"); + return Object.freeze({ schema: value.schema, recipeRevision: value.recipeRevision, routes: Object.freeze(routes), capabilities: Object.freeze(capabilities) }); +} + +export function canonicalBoundPolicyBytes(value) { return Buffer.from(`${JSON.stringify(validateBoundPolicy(value))}\n`, "utf8"); } +export function boundPolicyRevision(value) { const bytes = canonicalBoundPolicyBytes(value); return prefixed("bp1-sha256:", "bound-policy-v1", [bytes]); } +export function loadBoundPolicy(bytes) { + const raw = Buffer.from(bytes), value = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 8, label: "bound policy" }); + const policy = validateBoundPolicy(value), canonical = canonicalBoundPolicyBytes(policy); + if (!canonical.equals(raw)) fail("policy is not canonical"); + return Object.freeze({ policy, bytes: raw, revision: boundPolicyRevision(policy) }); +} + +const ROLE = /^(?:recipe|policy|instruction:[a-z0-9]+(?:-[a-z0-9]+)*|dispatch:iv1-sha256:[a-f0-9]{64}|output:iv1-sha256:[a-f0-9]{64}:[a-f0-9]{16})$/u; +export function validateArtifactRole(role) { if (typeof role !== "string" || !ROLE.test(role)) throw Object.assign(new Error("Loop artifact: invalid role"), { code: "ELOOP_ARTIFACT" }); return role; } + +export function validateArtifactBytes({ role, bytes, recipe, policy, run }) { + const raw = Buffer.from(bytes); validateArtifactRole(role); + if (role === "recipe") { + const frozenRecipe = loadFrozenRecipe(raw); + return { schema: "burnlist-loop-frozen@1", mediaType: "application/json", revision: frozenRecipe.revisions.executable, value: frozenRecipe }; + } + if (role === "policy") { + const loaded = loadBoundPolicy(raw); + if (recipe && loaded.policy.recipeRevision !== recipe.revisions.executable) fail("policy recipe binding mismatch"); + return { schema: "burnlist-loop-bound-policy@1", mediaType: "application/json", revision: loaded.revision, value: loaded.policy }; + } + if (role.startsWith("instruction:")) { + const id = role.slice("instruction:".length), expected = recipe?.instructions?.find((entry) => entry.id === id); + if (!expected || rawSha256(raw) !== expected.digest) throw Object.assign(new Error("Loop artifact: instruction does not match frozen recipe"), { code: "ELOOP_ARTIFACT" }); + return { schema: "burnlist-loop-instruction@1", mediaType: "text/markdown", revision: expected.digest, value: raw }; + } + if (role.startsWith("output:")) return { schema: "burnlist-loop-output-chunk@1", mediaType: "application/octet-stream", revision: rawSha256(raw), value: raw }; + const dispatch = validateDispatchAuthority(raw); + if (role !== `dispatch:${dispatch.value.invocationId}` || recipe && dispatch.value.recipeRevision !== recipe.revisions.executable + || policy && dispatch.value.policyRevision !== boundPolicyRevision(policy) + || run && (dispatch.value.runId !== run.runId || dispatch.value.assignmentId !== run.assignmentId + || dispatch.value.itemRevision !== run.itemRevision || !run.ownerClaim + || dispatch.value.claimId !== run.ownerClaim.claimId || dispatch.value.nodeId !== run.ownerClaim.nodeId + || dispatch.value.attempt !== run.ownerClaim.attempt || dispatch.value.inputCandidate !== run.ownerClaim.inputCandidate)) + throw Object.assign(new Error("Loop artifact: dispatch authority binding mismatch"), { code: "ELOOP_ARTIFACT" }); + return { schema: "burnlist-loop-dispatch-authority@1", mediaType: "application/json", revision: dispatch.digest, value: dispatch.value }; +} diff --git a/src/loops/run/run-claim.mjs b/src/loops/run/run-claim.mjs new file mode 100644 index 00000000..1dc2c7ed --- /dev/null +++ b/src/loops/run/run-claim.mjs @@ -0,0 +1,19 @@ +import { prefixed } from "../dsl/hash.mjs"; +import { RUN_ID, fail } from "./run-codec.mjs"; + +const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; + +export function ownerClaimId({ runId, nodeId, attempt, assignmentId, inputCandidate }) { + if (!RUN_ID.test(runId) || !SLUG.test(nodeId) || !Number.isInteger(attempt) || attempt < 1 || attempt > 100 + || !ASSIGNMENT.test(assignmentId) || !CANDIDATE.test(inputCandidate)) fail("invalid owner claim identity"); + return prefixed("cl1-sha256:", "claim-v1", [Buffer.from(runId), Buffer.from(nodeId), Buffer.from(String(attempt)), Buffer.from(assignmentId), Buffer.from(inputCandidate)]); +} + +export function createOwnerClaim(value) { + const claimId = ownerClaimId(value); + if (value.claimId !== undefined && value.claimId !== claimId) fail("fabricated owner claim id"); + return Object.freeze({ schema: "burnlist-loop-owner-claim@1", runId: value.runId, claimId, nodeId: value.nodeId, + attempt: value.attempt, assignmentId: value.assignmentId, inputCandidate: value.inputCandidate }); +} diff --git a/src/loops/run/run-clock.mjs b/src/loops/run/run-clock.mjs new file mode 100644 index 00000000..6c4b9bec --- /dev/null +++ b/src/loops/run/run-clock.mjs @@ -0,0 +1,7 @@ +export function processStartWall({ nowMilliseconds, uptimeSeconds }) { + if (!Number.isFinite(nowMilliseconds) || nowMilliseconds < 0 + || !Number.isFinite(uptimeSeconds) || uptimeSeconds < 0) { + throw Object.assign(new Error("Loop clock: invalid process-start inputs"), { code: "ELOOP_BUDGET" }); + } + return Math.max(0, Math.round(nowMilliseconds - (uptimeSeconds * 1_000))); +} diff --git a/src/loops/run/run-clock.test.mjs b/src/loops/run/run-clock.test.mjs new file mode 100644 index 00000000..f6ecfe87 --- /dev/null +++ b/src/loops/run/run-clock.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { processStartWall } from "./run-clock.mjs"; + +test("process start wall is stable across delayed module observation", () => { + assert.equal(processStartWall({ nowMilliseconds: 10_000, uptimeSeconds: 5 }), 5_000); + assert.equal(processStartWall({ nowMilliseconds: 20_000, uptimeSeconds: 15 }), 5_000); + assert.equal(processStartWall({ nowMilliseconds: 10_000, uptimeSeconds: 5.0006 }), 4_999); +}); + +test("process start wall rejects non-canonical inputs", () => { + assert.throws(() => processStartWall({ nowMilliseconds: -1, uptimeSeconds: 0 }), /invalid/u); + assert.throws(() => processStartWall({ nowMilliseconds: 1, uptimeSeconds: Number.NaN }), /invalid/u); +}); + +test("elapsed authority stays in journal budgets, not a process-local clock sample", async () => { + const { readFile } = await import("node:fs/promises"); + const source = await readFile(new URL("./run-clock.mjs", import.meta.url), "utf8"); + assert.doesNotMatch(source, /clockSample|processClock/u); +}); diff --git a/src/loops/run/run-codec.mjs b/src/loops/run/run-codec.mjs new file mode 100644 index 00000000..12044121 --- /dev/null +++ b/src/loops/run/run-codec.mjs @@ -0,0 +1,150 @@ +import { randomBytes } from "node:crypto"; +import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readFileSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { RUN_REF } from "./run-ref.mjs"; + +// A 128-bit ULID has two unused high bits in its 26-character base32 form. +// Reject noncanonical overflow spellings rather than giving them a path. +export const RUN_ID = RUN_REF; +export const ARTIFACT = /^artifact:sha256:[a-f0-9]{64}$/u; +export const RAW_DIGEST = /^sha256:[a-f0-9]{64}$/u; +export const MAX_ARTIFACT_BYTES = 1024 * 1024; +export const MAX_RECORD_BYTES = 64 * 1024; +export const MAX_RECORDS = 256; +export const MAX_RUN_JOURNAL_BYTES = 4 * 1024 * 1024; +export const MAX_RUN_ARTIFACT_BYTES = 8 * 1024 * 1024; +export const MAX_RUN_ARTIFACTS = 128; +export const MAX_RUNS = 128; +export const MAX_CATALOG_JOURNAL_BYTES = 32 * 1024 * 1024; +export const MAX_GLOBAL_ARTIFACTS = 1024; +export const MAX_GLOBAL_ARTIFACT_BYTES = 64 * 1024 * 1024; + +const BASE32 = "0123456789abcdefghjkmnpqrstvwxyz"; +const SCHEMA = /^[a-z][a-z0-9-]{0,95}@[1-9][0-9]*$/u; +const TYPE = /^[a-z][a-z0-9-]{0,63}$/u; + +export function fail(message, code = "ELOOP_RUN_STORE") { + throw Object.assign(new Error(`Loop Run store: ${message}`), { code }); +} + +export function exact(value, keys) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key, index) => Object.keys(value)[index] === key); +} + +export function validateRunId(value) { + if (!RUN_ID.test(value)) fail("invalid RunRef"); + return value; +} + +export function artifactDigest(bytes) { + return `artifact:${rawSha256(bytes)}`; +} + +export function artifactFileName(digest) { + if (!ARTIFACT.test(digest)) fail("invalid artifact digest"); + return digest.slice("artifact:sha256:".length); +} + +export function runDirectoryName(runId) { return validateRunId(runId).slice(4); } + +export function newRunId({ now = Date.now, random = randomBytes } = {}) { + const milliseconds = now(); + if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || milliseconds >= 2 ** 48) fail("invalid RunRef clock"); + const bytes = Buffer.alloc(16); + bytes.writeUIntBE(milliseconds, 0, 6); + const entropy = Buffer.from(random(10)); + if (entropy.length !== 10) fail("invalid RunRef entropy"); + entropy.copy(bytes, 6); + let value = 0n; + for (const byte of bytes) value = (value << 8n) | BigInt(byte); + let encoded = ""; + for (let index = 0; index < 26; index += 1) { encoded = BASE32[Number(value & 31n)] + encoded; value >>= 5n; } + return `run:${encoded}`; +} + +function canonicalValue(value, depth = 0) { + if (depth > 8) fail("journal payload exceeds depth"); + if (value === null || typeof value === "boolean" || typeof value === "string") return value; + if (typeof value === "number") { if (!Number.isSafeInteger(value) || value < 0) fail("journal payload has invalid number"); return value; } + if (Array.isArray(value)) { + if (value.length > 256) fail("journal payload has too many array values"); + return value.map((item) => canonicalValue(item, depth + 1)); + } + if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) fail("journal payload is not JSON data"); + const keys = Object.keys(value).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + if (keys.length > 128 || keys.some((key) => !key || Buffer.byteLength(key) > 128 || /[\0\r\n]/u.test(key))) fail("journal payload has invalid key"); + return Object.fromEntries(keys.map((key) => [key, canonicalValue(value[key], depth + 1)])); +} + +export function canonicalPayload(value) { + const canonical = canonicalValue(value); + const bytes = Buffer.from(JSON.stringify(canonical), "utf8"); + if (bytes.length > 32 * 1024) fail("journal payload exceeds bounds"); + return canonical; +} + +export function validateArtifactDescriptor(value) { + if (!exact(value, ["role", "digest", "schema", "mediaType", "byteLength", "revision"]) + || typeof value.role !== "string" || Buffer.byteLength(value.role) > 128 + || !ARTIFACT.test(value.digest) || typeof value.schema !== "string" || !SCHEMA.test(value.schema) + || typeof value.mediaType !== "string" || !/^[a-z][a-z0-9.+-]{0,63}\/[a-z0-9.+-]{1,127}$/u.test(value.mediaType) + || !Number.isSafeInteger(value.byteLength) || value.byteLength < 0 || value.byteLength > MAX_ARTIFACT_BYTES + || typeof value.revision !== "string" || !/^(?:(?:er1|bp1|iv1)-sha256|sha256):[a-f0-9]{64}$/u.test(value.revision)) fail("invalid artifact descriptor"); + return Object.freeze({ role: value.role, digest: value.digest, schema: value.schema, mediaType: value.mediaType, byteLength: value.byteLength, revision: value.revision }); +} + +export function canonicalArtifactDescriptors(values) { + if (!Array.isArray(values) || values.length > 128) fail("invalid artifact descriptor list"); + const descriptors = values.map(validateArtifactDescriptor).sort((left, right) => Buffer.compare(Buffer.from(left.role), Buffer.from(right.role))); + if (descriptors.some((item, index) => index && item.role === descriptors[index - 1].role)) fail("duplicate artifact role"); + return Object.freeze(descriptors); +} + +export function validateRecordType(value) { if (typeof value !== "string" || !TYPE.test(value)) fail("invalid journal record type"); return value; } + +export function assertContained(root, target) { + const parent = resolve(root), child = resolve(target), path = relative(parent, child); + if (path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))) return child; + fail("state path escapes the Loop store"); +} + +export function assertDirectory(path, label = "directory") { + const entry = lstatSync(path); + if (!entry.isDirectory() || entry.isSymbolicLink() || (entry.mode & 0o077) !== 0) fail(`${label} is not a private real directory`); + return entry; +} + +/** Descriptor-bound binary read with no-follow and stable identity checks. */ +export function readBoundedFile(path, maximum, label) { + if (!Number.isSafeInteger(maximum) || maximum < 0) fail("invalid byte limit"); + const before = lstatSync(path); + if (!before.isFile() || before.isSymbolicLink() || before.size > maximum || (before.mode & 0o077) !== 0) fail(`${label} is not a private bounded regular file`); + let fd; + try { + const noFollow = Number.isInteger(constants.O_NOFOLLOW) ? constants.O_NOFOLLOW : 0; + try { fd = openSync(path, constants.O_RDONLY | noFollow); } + catch (error) { + if (error?.code === "ELOOP") fail(`${label} is a symbolic link`); + if (!noFollow || !["EINVAL", "ENOTSUP", "EOPNOTSUPP"].includes(error?.code)) throw error; + fd = openSync(path, constants.O_RDONLY); + } + const opened = fstatSync(fd); + if (!opened.isFile() || opened.size > maximum || opened.dev !== before.dev || opened.ino !== before.ino) fail(`${label} changed while opening`); + const bytes = readFileSync(fd); + const completed = fstatSync(fd), after = lstatSync(path); + if (bytes.length > maximum || completed.dev !== opened.dev || completed.ino !== opened.ino || completed.size !== opened.size + || after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) fail(`${label} changed while reading`); + return bytes; + } finally { if (fd !== undefined) closeSync(fd); } +} + +export function fsyncFile(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +export function fsyncDirectory(path) { fsyncFile(path); } +export function journalName(sequence) { if (!Number.isSafeInteger(sequence) || sequence < 1 || sequence > MAX_RECORDS) fail("invalid journal sequence"); return `${String(sequence).padStart(16, "0")}.json`; } +export function journalSequence(name) { const match = /^(\d{16})\.json$/u.exec(name); if (!match) return null; const value = Number(match[1]); return value >= 1 && value <= MAX_RECORDS ? value : null; } +export function statePath(root, ...segments) { return assertContained(root, join(root, ...segments)); } +export function cleanTempName(name) { return /^\.[a-z0-9-]{1,96}\.[a-f0-9]{16,64}\.tmp$/u.test(name); } +export function parentPath(path) { return dirname(path); } +export function fileBase(path) { return basename(path); } diff --git a/src/loops/run/run-created.mjs b/src/loops/run/run-created.mjs new file mode 100644 index 00000000..437c1df9 --- /dev/null +++ b/src/loops/run/run-created.mjs @@ -0,0 +1,20 @@ +import { validateClockSample } from "./budgets.mjs"; +import { validateRunId, fail } from "./run-codec.mjs"; + +const RECIPE = /^er1-sha256:[a-f0-9]{64}$/u; +const POLICY = /^bp1-sha256:[a-f0-9]{64}$/u; +const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; +const ITEM = /^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const ITEM_DIGEST = /^id1-sha256:[a-f0-9]{64}$/u; + +export function creationPayload({ runId, assignmentId, itemRef, itemRevision, recipeRevision, policyRevision, descriptors, clock }) { + if (!validateRunId(runId) || !ASSIGNMENT.test(assignmentId) || !ITEM.test(itemRef) || !ITEM_DIGEST.test(itemRevision) + || !RECIPE.test(recipeRevision) || !POLICY.test(policyRevision)) fail("invalid Run creation identity"); + return { + schema: "burnlist-loop-run-created@1", runId, assignmentId, itemRef, itemRevision, recipeRevision, policyRevision, + recipeArtifact: descriptors.find((item) => item.role === "recipe").digest, + policyArtifact: descriptors.find((item) => item.role === "policy").digest, + instructionArtifacts: descriptors.filter((item) => item.role.startsWith("instruction:")).map((item) => item.digest), + clock: validateClockSample(clock), state: "prepared", + }; +} diff --git a/src/loops/run/run-fold.mjs b/src/loops/run/run-fold.mjs new file mode 100644 index 00000000..84289163 --- /dev/null +++ b/src/loops/run/run-fold.mjs @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; +import { isRunRef } from "./run-ref.mjs"; +import { foldStateMachine, validateGraph } from "./state-machine.mjs"; + +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const fail = (message) => { throw new Error(`Run fold: ${message}`); }; +export function foldRun(records) { + const first = records?.[0]?.value; + if (!first || first.type !== "run-created" || !exact(first.payload, ["schema", "runId", "itemRef", "graph", "authorityRequired"]) || first.payload.schema !== "burnlist-loop-m2-run@1" || !isRunRef(first.payload.runId) || typeof first.payload.itemRef !== "string" || typeof first.payload.authorityRequired !== "boolean") fail("invalid creation"); + validateGraph(first.payload.graph); + const execution = foldStateMachine({ graph: first.payload.graph, records }), last = records.at(-1); + const projection = Object.freeze({ schema: "burnlist-loop-m2-projection@1", runId: first.payload.runId, itemRef: first.payload.itemRef, state: execution.state, currentNode: execution.nodeId, attempt: execution.attempt, cycle: execution.cycle, generation: execution.generation, leaseHeld: Boolean(execution.lease), counters: execution.budget.counters, journal: execution.budget.journal, latestMaker: execution.latest.maker, latestCheck: execution.latest.check, latestReviewer: execution.latest.reviewer, sequence: last.value.sequence, journalDigest: last.digest }); + const bytes = Buffer.from(`${JSON.stringify(projection)}\n`), revision = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + return Object.freeze({ graph: first.payload.graph, execution, projection, bytes, revision }); +} diff --git a/src/loops/run/run-fold.test.mjs b/src/loops/run/run-fold.test.mjs new file mode 100644 index 00000000..b959f93a --- /dev/null +++ b/src/loops/run/run-fold.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJournalRecord } from "./run-journal.mjs"; +import { foldRun } from "./run-fold.mjs"; +import { created, testGraph, testRunId } from "./m2-test-fixtures.mjs"; + +const add = (records, type, payload, at = records.length) => [...records, createJournalRecord({ sequence: records.length + 1, prevDigest: records.at(-1)?.digest ?? null, at, type, payload })]; +const start = () => [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; +test("fold validates canonical RunRef, result bytes, and lifecycle terminal authority", () => { + const bad = { ...created(), runId: "run:not-a-run-ref" }; + assert.throws(() => foldRun([createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: bad })]), /creation/u); + let records = start(); records = add(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); records = add(records, "lease-acquired", { generation: 1, token: "a".repeat(64) }); + assert.throws(() => foldRun(add(records, "state-changed", { from: "running", to: "converged", cause: "graph" })), /bypass/u); + records = add(records, "node-started", { nodeId: "implement", attempt: 1 }); records = add(records, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "b".repeat(32) }); + assert.throws(() => foldRun(add(records, "invocation-result", { invocationId: "b".repeat(32), kind: "complete", summary: "x".repeat(1025), outputBytes: 0, candidateId: null })), /result/u); + assert.equal(foldRun(records).graph, testGraph); +}); diff --git a/src/loops/run/run-journal.mjs b/src/loops/run/run-journal.mjs new file mode 100644 index 00000000..f9364008 --- /dev/null +++ b/src/loops/run/run-journal.mjs @@ -0,0 +1,51 @@ +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export const MAX_JOURNAL_RECORDS = 256; +const MAX_RECORDS = MAX_JOURNAL_RECORDS, MAX_BYTES = 4 * 1024 * 1024, MAX_RECORD = 131072; +const TYPES = new Set(["run-created", "lease-acquired", "lease-released", "lease-revoked", "state-changed", "node-started", "invocation-started", "invocation-result", "candidate-bound", "system-outcome", "terminal-node-committed", "failure-routed", "edge-taken"]); +const names = { "run-created": ["schema", "runId", "itemRef", "graph", "authorityRequired"], "lease-acquired": ["generation", "token"], "lease-released": ["generation", "token"], "lease-revoked": ["generation", "token"], "state-changed": ["from", "to", "cause"], "node-started": ["nodeId", "attempt"], "invocation-started": ["nodeId", "attempt", "invocationId"], "system-outcome": ["kind", "summary"], "terminal-node-committed": ["kind", "summary", "from", "to", "nodeId", "attempt"], "invocation-result": ["invocationId", "kind", "summary", "outputBytes", "candidateId"], "candidate-bound": ["candidateId", "candidateContext"], "failure-routed": ["from", "kind", "to"], "edge-taken": ["from", "on", "to"] }; +const fileName = (sequence) => `${String(sequence).padStart(16, "0")}.json`; +const tempName = /^\.append-[a-f0-9]{16}\.tmp$/u; +const fail = (message, code = "EJOURNAL") => { throw Object.assign(new Error(`Run journal: ${message}`), { code }); }; +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + +export function validateJournalEvent(type, payload) { if (!TYPES.has(type) || !exact(payload, names[type])) fail("event payload is not closed"); return payload; } +export function createJournalRecord({ sequence, prevDigest, at, type, payload }) { + if (!Number.isSafeInteger(sequence) || sequence < 1 || !Number.isSafeInteger(at) || at < 0 || !(prevDigest === null || /^sha256:[a-f0-9]{64}$/u.test(prevDigest))) fail("invalid record header"); + validateJournalEvent(type, payload); + const value = { schema: "burnlist-loop-m2-journal@1", sequence, prevDigest, at, type, payload }, bytes = Buffer.from(`${JSON.stringify(value)}\n`); + if (bytes.length > MAX_RECORD || !exact(value, ["schema", "sequence", "prevDigest", "at", "type", "payload"])) fail("record exceeds bounds"); + return Object.freeze({ value: Object.freeze(value), bytes, digest: digest(bytes) }); +} +export function parseJournalRecord(bytes) { + let value; try { value = JSON.parse(Buffer.from(bytes).toString("utf8")); } catch { fail("record is not JSON"); } + if (!exact(value, ["schema", "sequence", "prevDigest", "at", "type", "payload"]) || value.schema !== "burnlist-loop-m2-journal@1") fail("record is not closed"); + const record = createJournalRecord(value); if (!record.bytes.equals(bytes)) fail("record is not canonical"); return record; +} +export function readJournal(directory) { + const entries = readdirSync(directory, { withFileTypes: true }); if (entries.length > MAX_RECORDS + 1) fail("too many journal entries"); + const records = []; let temporary = 0, aggregate = 0; + for (const entry of entries) { + const path = join(directory, entry.name), stat = lstatSync(path); + if (tempName.test(entry.name)) { if (!entry.isFile() || entry.isSymbolicLink() || stat.size > MAX_RECORD || ++temporary > 1) fail("invalid temporary tail"); continue; } + const match = /^(\d{16})\.json$/u.exec(entry.name); if (!match || !entry.isFile() || entry.isSymbolicLink()) fail("invalid journal entry"); + aggregate += stat.size; if (stat.size < 2 || stat.size > MAX_RECORD || aggregate > MAX_BYTES) fail("journal exceeds bounds"); + records.push({ sequence: Number(match[1]), bytes: readFileSync(path) }); + } + if (!records.length || records.length > MAX_RECORDS) fail("journal is empty or too large"); records.sort((a, b) => a.sequence - b.sequence); + return Object.freeze(records.map((entry, index) => { if (entry.sequence !== index + 1) fail("sequence is discontinuous"); const record = parseJournalRecord(entry.bytes), prior = records[index - 1]?.record; if (record.value.sequence !== entry.sequence || record.value.prevDigest !== (prior?.digest ?? null)) fail("sequence or hash chain is invalid"); entry.record = record; return record; })); +} +function durableCreate(path, bytes) { let fd; try { fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, bytes); fsyncSync(fd); } finally { if (fd !== undefined) closeSync(fd); } } +function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } +export function writeInitialJournal({ runDirectory, payload, at }) { const directory = join(runDirectory, "journal"); mkdirSync(directory, { mode: 0o700 }); const record = createJournalRecord({ sequence: 1, prevDigest: null, at, type: "run-created", payload }); durableCreate(join(directory, fileName(1)), record.bytes); syncDirectory(directory); return record; } +export function appendJournalRecord({ journalDirectory, record, hooks = {} }) { + const existing = readJournal(journalDirectory), prior = existing.at(-1), aggregate = existing.reduce((total, item) => total + item.bytes.length, 0); + if (existing.length >= MAX_RECORDS || aggregate + record.bytes.length > MAX_BYTES) fail("prospective journal exceeds bounds"); + if (record.value.sequence !== prior.value.sequence + 1 || record.value.prevDigest !== prior.digest) fail("append precondition failed", "ESTALE"); + const temporary = join(journalDirectory, `.append-${randomBytes(8).toString("hex")}.tmp`), target = join(journalDirectory, fileName(record.value.sequence)); + try { durableCreate(temporary, record.bytes); hooks.beforePublish?.({ record, target }); linkSync(temporary, target); unlinkSync(temporary); syncDirectory(journalDirectory); return record; } finally { rmSync(temporary, { force: true }); } +} +/** Preflights the whole contiguous batch before publishing any member. */ diff --git a/src/loops/run/run-journal.test.mjs b/src/loops/run/run-journal.test.mjs new file mode 100644 index 00000000..1afcdd5f --- /dev/null +++ b/src/loops/run/run-journal.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { appendJournalRecord, createJournalRecord, readJournal, writeInitialJournal } from "./run-journal.mjs"; +import { created } from "./m2-test-fixtures.mjs"; + +test("journal accepts one exact writer tail and rejects malformed storage", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"); + writeFileSync(join(directory, ".append-0123456789abcdef.tmp"), "partial"); assert.equal(readJournal(directory).length, 1); + writeFileSync(join(directory, ".other.tmp"), "partial"); assert.throws(() => readJournal(directory), /invalid journal entry/u); + rmSync(join(directory, ".other.tmp")); writeFileSync(join(directory, ".append-fedcba9876543210.tmp"), "partial"); assert.throws(() => readJournal(directory), /temporary tail/u); +}); +test("journal bounds records before publication and retains a valid old tail", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"), prior = readJournal(directory).at(-1); + const record = createJournalRecord({ sequence: 2, prevDigest: prior.digest, at: 1, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }); + assert.throws(() => appendJournalRecord({ journalDirectory: directory, record: { ...record, value: { ...record.value, sequence: 9 } } }), /append precondition/u); + assert.equal(readJournal(directory).length, 1); +}); +test("a canonical record whose sequence disagrees with its filename is rejected", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"); + const first = readJournal(directory)[0]; + writeFileSync(join(directory, "0000000000000002.json"), first.bytes); + assert.throws(() => readJournal(directory), /sequence or hash chain/u); + rmSync(join(directory, "0000000000000002.json")); + assert.equal(readJournal(directory).length, 1); +}); +test("the 256th record remains replayable and the 257th is rejected before link", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"); + for (let sequence = 2; sequence <= 256; sequence += 1) { + const prior = readJournal(directory).at(-1); + appendJournalRecord({ journalDirectory: directory, record: createJournalRecord({ sequence, prevDigest: prior.digest, at: sequence, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }) }); + } + assert.equal(readJournal(directory).length, 256); + const prior = readJournal(directory).at(-1); + assert.throws(() => appendJournalRecord({ journalDirectory: directory, record: createJournalRecord({ sequence: 257, prevDigest: prior.digest, at: 257, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }) }), /prospective journal/u); + assert.equal(readJournal(directory).length, 256); +}); +test("the final journal slot atomically retains a complete terminal outcome", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-journal-")); t.after(() => rmSync(root, { recursive: true, force: true })); + writeInitialJournal({ runDirectory: root, at: 0, payload: created() }); const directory = join(root, "journal"); + for (let sequence = 2; sequence <= 255; sequence += 1) { const prior = readJournal(directory).at(-1); appendJournalRecord({ journalDirectory: directory, record: createJournalRecord({ sequence, prevDigest: prior.digest, at: sequence, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }) }); } + const prior = readJournal(directory).at(-1), terminal = createJournalRecord({ sequence: 256, prevDigest: prior.digest, at: 256, type: "terminal-node-committed", payload: { kind: "exhausted", summary: "minutes", from: "running", to: "budget-exhausted", nodeId: "exhausted", attempt: 1 } }); + appendJournalRecord({ journalDirectory: directory, record: terminal }); assert.equal(readJournal(directory).length, 256); assert.equal(readJournal(directory).at(-1).value.type, "terminal-node-committed"); +}); diff --git a/src/loops/run/run-record.mjs b/src/loops/run/run-record.mjs new file mode 100644 index 00000000..743c79d8 --- /dev/null +++ b/src/loops/run/run-record.mjs @@ -0,0 +1,103 @@ +import { ARTIFACT, RAW_DIGEST, fail, validateRunId } from "./run-codec.mjs"; +import { validateClockSample } from "./budgets.mjs"; +import { clockAnomalyTransitionPayload, legacyTransitionPayload, stateTransitionPayload } from "./lifecycle.mjs"; +import { validateOperationIntent } from "./operation.mjs"; + +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const INVOCATION = /^iv1-sha256:[a-f0-9]{64}$/u; +const OPERATION = /^op1-sha256:[a-f0-9]{64}$/u; +const NODE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const OUTCOMES = new Set(["pass", "fail", "reject", "approve", "complete", "escalate"]); +const SYSTEM_OUTCOMES = new Set(["error", "timeout", "cancelled", "lost", "exhausted"]); +const TERMINALS = new Set(["converged", "needs-human", "failed", "stopped", "budget-exhausted"]); +const TYPES = new Set(["run-created", "owner-claim-acquired", "owner-claim-released", "run-state-transition", + "state-transition", "clock-anomaly-transition", "clock-sampled", "node-entered", "spawn-intent", "output-captured", + "output-limit-exceeded", "edge-traversed", "graph-outcome", "system-outcome", "operation-intent", "operation-completed"]); + +function integer(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) { + return Number.isSafeInteger(value) && value >= minimum && value <= maximum; +} +function exact(value, keys) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} +function digest(value, prefix) { return new RegExp(`^${prefix}:[a-f0-9]{64}$`, "u").test(value); } +function noControls(value, maximum = 512) { + return typeof value === "string" && Buffer.byteLength(value) > 0 && Buffer.byteLength(value) <= maximum && !/[\0\r\n]/u.test(value); +} +function claim(value) { + if (!exact(value, ["schema", "runId", "claimId", "nodeId", "attempt", "assignmentId", "inputCandidate"]) + || value.schema !== "burnlist-loop-owner-claim@1" || !validateRunId(value.runId) || !CLAIM.test(value.claimId) + || !NODE.test(value.nodeId) || !integer(value.attempt, 1, 100) || !digest(value.assignmentId, "as1-sha256") + || !digest(value.inputCandidate, "cm1-sha256")) fail("invalid owner claim record"); +} +function created(value) { + if (!exact(value, ["schema", "runId", "assignmentId", "itemRef", "itemRevision", "recipeRevision", "policyRevision", + "recipeArtifact", "policyArtifact", "instructionArtifacts", "clock", "state"]) + || value.schema !== "burnlist-loop-run-created@1" || !validateRunId(value.runId) || !digest(value.assignmentId, "as1-sha256") + || !/^item:[0-9]{6}-[0-9]{3}#[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(value.itemRef) + || !digest(value.itemRevision, "id1-sha256") || !digest(value.recipeRevision, "er1-sha256") + || !digest(value.policyRevision, "bp1-sha256") || !ARTIFACT.test(value.recipeArtifact) || !ARTIFACT.test(value.policyArtifact) + || !Array.isArray(value.instructionArtifacts) || value.instructionArtifacts.length !== 2 + || value.instructionArtifacts.some((item) => !ARTIFACT.test(item)) || value.state !== "prepared") fail("invalid Run creation record"); + validateClockSample(value.clock); +} +function output(value) { + if (!exact(value, ["schema", "claimId", "invocationId", "byteLength", "digest", "artifact"]) + || value.schema !== "burnlist-loop-output-captured@1" || !CLAIM.test(value.claimId) || !INVOCATION.test(value.invocationId) + || !integer(value.byteLength, 1, 16_384) || !RAW_DIGEST.test(value.digest) || !ARTIFACT.test(value.artifact)) fail("invalid output capture record"); +} +function spawn(value) { + if (!exact(value, ["schema", "nodeId", "attempt", "claimId", "invocationId", "launchAuthorityDigest"]) + || value.schema !== "burnlist-loop-spawn-intent@1" || !NODE.test(value.nodeId) || !integer(value.attempt, 1, 100) + || !CLAIM.test(value.claimId) || !INVOCATION.test(value.invocationId) || !RAW_DIGEST.test(value.launchAuthorityDigest)) fail("invalid spawn intent record"); +} +function edge(value) { + if (!exact(value, ["schema", "from", "on", "to", "visit", "claimId", "invocationId"]) + || value.schema !== "burnlist-loop-edge-traversed@1" || !NODE.test(value.from) || !NODE.test(value.to) + || !OUTCOMES.has(value.on) || !integer(value.visit, 1, 100) + || !(value.claimId === null || CLAIM.test(value.claimId)) || !(value.invocationId === null || INVOCATION.test(value.invocationId))) fail("invalid edge traversal record"); +} + +/** Validates the closed Stage 1 journal union before any record can be hashed or replayed. */ +export function validateJournalEvent(type, payload, artifacts) { + if (!TYPES.has(type)) fail("unknown journal record type"); + if (!Array.isArray(artifacts)) fail("invalid journal artifact list"); + if (type === "run-created") created(payload); + else if (type === "owner-claim-acquired") claim(payload); + else if (type === "owner-claim-released") { + if (!exact(payload, ["schema", "runId", "claimId"]) || payload.schema !== "burnlist-loop-owner-claim-release@1" + || !validateRunId(payload.runId) || !CLAIM.test(payload.claimId)) fail("invalid owner claim release record"); + } else if (type === "run-state-transition") stateTransitionPayload(payload); + else if (type === "state-transition") legacyTransitionPayload(payload); + else if (type === "clock-anomaly-transition") clockAnomalyTransitionPayload(payload); + else if (type === "clock-sampled") validateClockSample(payload); + else if (type === "node-entered") { + if (!exact(payload, ["schema", "nodeId", "attempt", "claimId"]) || payload.schema !== "burnlist-loop-node-entered@1" + || !NODE.test(payload.nodeId) || !integer(payload.attempt, 1, 100) || !CLAIM.test(payload.claimId)) fail("invalid node entry record"); + } else if (type === "spawn-intent") spawn(payload); + else if (type === "output-captured") output(payload); + else if (type === "output-limit-exceeded") { + if (!exact(payload, ["schema", "claimId", "invocationId", "reason"]) + || payload.schema !== "burnlist-loop-output-limit-exceeded@1" || !CLAIM.test(payload.claimId) + || !INVOCATION.test(payload.invocationId) || !["output-bytes", "deadline"].includes(payload.reason)) fail("invalid output-limit record"); + } else if (type === "edge-traversed") edge(payload); + else if (type === "graph-outcome") { + if (!exact(payload, ["schema", "nodeId", "state"]) || payload.schema !== "burnlist-loop-graph-outcome@1" + || !NODE.test(payload.nodeId) || !TERMINALS.has(payload.state)) fail("invalid graph outcome record"); + } else if (type === "system-outcome") { + if (!exact(payload, ["schema", "outcome", "nodeId", "state", "reason"]) + || payload.schema !== "burnlist-loop-system-outcome@1" || !SYSTEM_OUTCOMES.has(payload.outcome) + || !NODE.test(payload.nodeId) || !TERMINALS.has(payload.state) || !noControls(payload.reason)) fail("invalid system outcome record"); + } else if (type === "operation-intent") validateOperationIntent(payload); + else if (type === "operation-completed") { + if (!exact(payload, ["schema", "operationId"]) || payload.schema !== "burnlist-loop-operation-completed@1" || !OPERATION.test(payload.operationId)) fail("invalid operation completion record"); + } + if (type === "run-created" ? artifacts.length !== 4 : type === "output-captured" ? artifacts.length !== 1 : artifacts.length !== 0) + fail("journal record has an invalid artifact set"); + if (type === "output-captured") { + const artifact = artifacts[0]; + if (artifact.digest !== payload.artifact || artifact.revision !== payload.digest || artifact.byteLength !== payload.byteLength + || artifact.schema !== "burnlist-loop-output-chunk@1" || artifact.mediaType !== "application/octet-stream") fail("output artifact record binding is invalid"); + } +} diff --git a/src/loops/run/run-ref.mjs b/src/loops/run/run-ref.mjs new file mode 100644 index 00000000..ec26a2a8 --- /dev/null +++ b/src/loops/run/run-ref.mjs @@ -0,0 +1,6 @@ +/** Canonical 128-bit RunRef encoded as 26 lowercase Crockford digits. */ +export const RUN_REF = /^run:[0-7][0-9a-hjkmnp-tv-z]{25}$/u; + +export function isRunRef(value) { + return typeof value === "string" && RUN_REF.test(value); +} diff --git a/src/loops/run/run-result.mjs b/src/loops/run/run-result.mjs new file mode 100644 index 00000000..7ba65cc8 --- /dev/null +++ b/src/loops/run/run-result.mjs @@ -0,0 +1,16 @@ +const SYSTEM = new Set(["error", "timeout", "cancelled", "lost", "exhausted"]); +const semantic = { + task: new Set(["complete"]), review: new Set(["approve", "reject", "escalate"]), check: new Set(["pass", "fail"]), +}; +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const fail = (message) => { throw Object.assign(new Error(`Run result: ${message}`), { code: "ERESULT" }); }; + +export function isSystemOutcome(kind) { return SYSTEM.has(kind); } +export function validateNormalizedResult(value, node, maximumOutputBytes) { + if (!exact(value, ["kind", "summary", "outputBytes", "candidateId"]) || typeof value.kind !== "string" || typeof value.summary !== "string" + || Buffer.byteLength(value.summary, "utf8") > 1024 || !Number.isSafeInteger(value.outputBytes) || value.outputBytes < 0 || value.outputBytes > maximumOutputBytes) fail("invalid normalized result"); + if (!(value.candidateId === null || /^cm1-sha256:[a-f0-9]{64}$/u.test(value.candidateId))) fail("invalid result candidate"); + const allowed = node?.kind === "agent" ? semantic[node.mode] : semantic[node?.kind]; + if (!SYSTEM.has(value.kind) && !allowed?.has(value.kind)) fail("outcome is not legal for node"); + return Object.freeze({ ...value }); +} diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs new file mode 100644 index 00000000..cb7879bd --- /dev/null +++ b/src/loops/run/run-store.mjs @@ -0,0 +1,153 @@ +import { randomBytes } from "node:crypto"; +import { closeSync, constants, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { isRunRef } from "./run-ref.mjs"; +import { appendJournalRecord, createJournalRecord, MAX_JOURNAL_RECORDS, readJournal, writeInitialJournal } from "./run-journal.mjs"; +import { foldRun } from "./run-fold.mjs"; +import { atomicTerminalState, isTerminalState, validateGraph } from "./state-machine.mjs"; +import { parseBoundedObject } from "../contracts/contract.mjs"; +import { publishLoopProjectionInvalidation } from "../events/projection-events.mjs"; +import { currentRunAuthority } from "./current-authority.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; + +const fail = (message, code = "ERUN_STORE") => { throw Object.assign(new Error(`Run store: ${message}`), { code }); }; +const runName = (id) => Buffer.from(id).toString("hex"); +export function runStore(repoRoot, { clock = () => Date.now(), random = randomBytes, hooks = {}, publishProjection = publishLoopProjectionInvalidation } = {}) { + const root = resolve(repoRoot), base = join(root, ".local", "burnlist", "loop", "m2"), runs = join(base, "runs"), now = () => { const value = clock(); if (!Number.isSafeInteger(value) || value < 0) fail("invalid clock"); return value; }; + const pathFor = (id) => join(runs, runName(id)), journalFor = (id) => join(pathFor(id), "journal"), lockFor = (id) => join(pathFor(id), ".lock"), proofPath = (id) => join(pathFor(id), ".recovery-proof"), authorityPath = (id) => join(pathFor(id), "dispatch-authority.json"), currentLock = join(base, ".current-runs.lock"), initialize = () => mkdirSync(runs, { recursive: true, mode: 0o700 }), currentAuthority = () => currentRunAuthority({ root, base, random }); + const assertId = (id) => { if (!isRunRef(id)) fail("invalid RunRef"); return id; }; + const locked = (id, fn) => { assertId(id); initialize(); return withDirectoryLock({ lockPath: lockFor(id), reclaimLiveAfterAge: false, errorFactory: () => fail("run is locked", "ELOCKED"), fn }); }; + const replay = (id) => { + assertId(id); + if (!existsSync(journalFor(id))) fail("run is missing", "ENOENT"); + const journal = readJournal(journalFor(id)), folded = foldRun(journal); + if (folded.projection.runId !== id) fail("run identity mismatch"); + let loopIdentity = Object.freeze({ loopId: folded.graph.id, loopRevision: null }); + if (existsSync(authorityPath(id))) { + try { + const authority = readAuthority(id), frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); + if (authority.itemRef !== folded.projection.itemRef) fail("sealed authority item does not match Run journal", "EAUTHORITY"); + if (JSON.stringify(frozen.ir) !== JSON.stringify(folded.graph)) fail("sealed recipe does not match Run graph", "EAUTHORITY"); + loopIdentity = Object.freeze({ loopId: frozen.ir.id, loopRevision: frozen.revisions.executable }); + } catch (error) { + if (error?.code === "EAUTHORITY") throw error; + fail("sealed dispatch authority is corrupt", "EAUTHORITY"); + } + } else if (journal[0].value.payload.authorityRequired) fail("sealed dispatch authority is unavailable", "EAUTHORITY"); + return Object.freeze({ runId: id, journal, loopIdentity, ...folded }); + }; + const retainsTerminalReserve = (current, writes = 1) => current.projection.sequence + writes < MAX_JOURNAL_RECORDS; + const terminalKind = { converged: "converged", "needs-human": "lost", failed: "error", stopped: "cancelled", "budget-exhausted": "exhausted" }; + function prospective(id, current, type, payload, at = now()) { + if (!retainsTerminalReserve(current)) fail("journal terminal reserve is required", "EJOURNAL"); + const record = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type, payload }); + foldRun([...current.journal, record]); // reject poison before publication + appendJournalRecord({ journalDirectory: journalFor(id), record }); + return Object.freeze({ record, ...replay(id) }); + } + function assertLease(current, lease) { const held = current.execution.lease; if (!lease || !held || lease.generation !== held.generation || lease.token !== held.token) fail("stale lease", "ESTALE_LEASE"); } + function syncDirectory(path) { const fd = openSync(path, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } } + function syncParent(id) { syncDirectory(pathFor(id)); } + function writeRecoveryProof(id, value) { const checked = { schema: "burnlist-loop-m2-recovery-proof@1", runId: id, generation: value.generation, token: value.token, recoveryProof: value.recoveryProof }, bytes = Buffer.from(`${JSON.stringify(checked)}\n`), temporary = `${proofPath(id)}.${random(8).toString("hex")}.tmp`; if (bytes.length > 1024) fail("recovery proof exceeds bounds"); let fd; try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, proofPath(id)); syncParent(id); } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } } + function readRecoveryProof(id) { let fd; try { const path = proofPath(id), entry = lstatSync(path); if (!entry.isFile() || entry.isSymbolicLink()) fail("recovery proof is corrupt"); fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); const before = fstatSync(fd); if (!before.isFile() || (before.mode & 0o777) !== 0o600 || before.size < 2 || before.size > 1024) fail("recovery proof is corrupt"); const bytes = Buffer.alloc(before.size); if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("recovery proof changed while reading"); const after = fstatSync(fd); if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) fail("recovery proof changed while reading"); const value = JSON.parse(bytes.toString("utf8")); if (!value || Object.keys(value).length !== 5 || value.schema !== "burnlist-loop-m2-recovery-proof@1" || value.runId !== id || !Number.isSafeInteger(value.generation) || !/^[a-f0-9]{64}$/u.test(value.token) || !/^[a-f0-9]{64}$/u.test(value.recoveryProof)) fail("recovery proof is corrupt"); return value; } catch (error) { if (error?.code === "ENOENT") fail("lost-owner proof is unavailable", "ELOST_PROOF"); throw error; } finally { if (fd !== undefined) closeSync(fd); } } + function clearRecoveryProof(id) { rmSync(proofPath(id), { force: true }); syncParent(id); } + function sealedAuthority(id, value) { + const keys = ["schema", "runId", "assignmentId", "itemRef", "itemRevision", "itemText", "frozenRecipe", "policy"]; + if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every((key, index) => Object.keys(value)[index] === key) + || value.schema !== "burnlist-loop-m12-run-authority@1" || value.runId !== id || !/^as1-sha256:[a-f0-9]{64}$/u.test(value.assignmentId) + || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.itemRevision) || typeof value.itemRef !== "string" || !value.itemRef + || typeof value.itemText !== "string" || !value.itemText || Buffer.byteLength(value.itemText) > 65_536 + || typeof value.frozenRecipe !== "string" || typeof value.policy !== "string") fail("invalid sealed dispatch authority", "EAUTHORITY"); + for (const field of ["frozenRecipe", "policy"]) { + const bytes = Buffer.from(value[field], "base64"); + if (!bytes.length || bytes.length > 262_144 || bytes.toString("base64") !== value[field]) fail("invalid sealed dispatch authority", "EAUTHORITY"); + } + const bytes = Buffer.from(`${JSON.stringify(value)}\n`); + if (bytes.length > 700_000) fail("sealed dispatch authority exceeds bounds", "EAUTHORITY"); + return Object.freeze({ value: Object.freeze({ ...value }), bytes }); + } + function writeAuthorityAt(directory, id, value) { + const sealed = sealedAuthority(id, value), target = join(directory, "dispatch-authority.json"), temporary = `${target}.${random(8).toString("hex")}.tmp`; + let fd; + try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, sealed.bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, target); syncDirectory(directory); } + finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } + function readAuthority(id) { + let fd; + try { + const target = authorityPath(id), entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 || entry.size < 2 || entry.size > 700_000) fail("sealed dispatch authority is corrupt", "EAUTHORITY"); + fd = openSync(target, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); const before = fstatSync(fd); + if (!before.isFile() || before.dev !== entry.dev || before.ino !== entry.ino || before.size !== entry.size) fail("sealed dispatch authority changed while opening", "EAUTHORITY"); + const bytes = Buffer.alloc(before.size); if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("sealed dispatch authority changed while reading", "EAUTHORITY"); + const after = fstatSync(fd), linked = lstatSync(target); if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || linked.isSymbolicLink() || linked.dev !== before.dev || linked.ino !== before.ino || linked.size !== before.size) fail("sealed dispatch authority changed while reading", "EAUTHORITY"); + const parsed = parseBoundedObject(bytes, { maximumBytes: 700_000, maximumDepth: 3, label: "sealed dispatch authority" }); + const sealed = sealedAuthority(id, parsed); if (!sealed.bytes.equals(bytes)) fail("sealed dispatch authority is not canonical", "EAUTHORITY"); return sealed.value; + } catch (error) { + if (error?.code === "ENOENT") fail("sealed dispatch authority is unavailable", "EAUTHORITY"); + if (error?.code === "EAUTHORITY") throw error; + fail("sealed dispatch authority is corrupt", "EAUTHORITY"); + } + finally { if (fd !== undefined) closeSync(fd); } + } + function terminalizeCurrent(id, current, { kind = "exhausted", summary = "journal" } = {}, at = now()) { + if (current.projection.sequence >= MAX_JOURNAL_RECORDS) fail("journal has no terminal capacity", "EJOURNAL"); + const selected = current.execution.system ?? (isTerminalState(current.projection.state) ? { kind: terminalKind[current.projection.state], summary: "journal-cleanup" } : { kind, summary }), targetState = isTerminalState(current.projection.state) ? current.projection.state : atomicTerminalState(selected.kind), targetNode = selected.kind === "converged" ? current.graph.nodes.find((node) => node.kind === "terminal" && node.state === "converged")?.id : current.graph.failurePolicy[selected.kind], alreadyStarted = current.execution.nodeId === targetNode && current.execution.started, attempt = alreadyStarted ? current.execution.attempts[targetNode] : (current.execution.attempts[targetNode] ?? 0) + 1, record = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type: "terminal-node-committed", payload: { kind: selected.kind, summary: selected.summary, from: current.projection.state, to: targetState, nodeId: targetNode, attempt } }); + foldRun([...current.journal, record]); appendJournalRecord({ journalDirectory: journalFor(id), record }); clearRecoveryProof(id); + return Object.freeze({ record, ...replay(id) }); + } + const append = (id, lease, type, payload) => locked(id, () => { const current = replay(id); assertLease(current, lease); const at = now(); if (current.projection.sequence >= MAX_JOURNAL_RECORDS) fail("journal has no terminal capacity", "EJOURNAL"); const candidate = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type, payload }), folded = foldRun([...current.journal, candidate]); if (type === "state-changed" && isTerminalState(payload.to)) return terminalizeCurrent(id, current, { kind: terminalKind[payload.to], summary: payload.cause }, at); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current, {}, at); if (["node-started", "invocation-started", "invocation-result", "edge-taken"].includes(type) && folded.execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return terminalizeCurrent(id, current, { summary: "minutes" }, at); appendJournalRecord({ journalDirectory: journalFor(id), record: candidate }); return Object.freeze({ record: candidate, ...replay(id) }); }); + const acquireLease = (id) => locked(id, () => { + let current = replay(id); if (current.execution.lease) fail("run already has a lease", "ELEASED"); if (isTerminalState(current.projection.state)) fail("run is terminal"); + const writes = ["prepared", "paused"].includes(current.projection.state) ? 2 : 1; if (!retainsTerminalReserve(current, writes)) return terminalizeCurrent(id, current); + if (["prepared", "paused"].includes(current.projection.state)) { current = prospective(id, current, "state-changed", { from: current.projection.state, to: "running", cause: "control" }); } + const lease = Object.freeze({ generation: current.execution.generation + 1, token: random(32).toString("hex") }), recoveryProof = random(32).toString("hex"); hooks.beforeProofPublish?.({ id, lease }); writeRecoveryProof(id, { generation: lease.generation, token: lease.token, recoveryProof }); hooks.afterProofPublish?.({ id, lease, recoveryProof }); try { prospective(id, current, "lease-acquired", lease); } catch (error) { clearRecoveryProof(id); throw error; } hooks.afterLeaseAppend?.({ id, lease, recoveryProof }); return Object.freeze({ lease, recoveryProof, ...replay(id) }); + }); + const releaseLease = (id, lease) => locked(id, () => { const current = replay(id); assertLease(current, lease); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current); const result = prospective(id, current, "lease-released", { generation: lease?.generation, token: lease?.token }); clearRecoveryProof(id); return result; }); + const recoverLease = (id, proof) => locked(id, () => { const current = replay(id), expected = readRecoveryProof(id); if (!proof || proof.generation !== expected.generation || proof.recoveryProof !== expected.recoveryProof) fail("lost-owner proof is invalid", "ELOST_PROOF"); const held = current.execution.lease; if (!held || held.generation !== proof.generation || held.token !== expected.token) fail("owner generation changed", "ESTALE_LEASE"); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current); const result = prospective(id, current, "lease-revoked", { generation: held.generation, token: held.token }); clearRecoveryProof(id); return result; }); + const terminalize = (id, lease, kind, summary) => locked(id, () => { const current = replay(id); assertLease(current, lease); return terminalizeCurrent(id, current, { kind, summary }); }); + function createRun({ runId, itemRef, graph, authority = null }) { + assertId(runId); if (typeof itemRef !== "string" || !itemRef || itemRef.length > 512) fail("invalid creation input"); validateGraph(graph); initialize(); + const target = pathFor(runId), staging = join(runs, `.create-${random(8).toString("hex")}.tmp`); + if (existsSync(target)) fail("run already exists", "EEXIST"); mkdirSync(staging, { recursive: false, mode: 0o700 }); + try { + const current = authority ? sealedAuthority(runId, authority).value : null; + if (authority) writeAuthorityAt(staging, runId, authority); + writeInitialJournal({ runDirectory: staging, at: now(), payload: { schema: "burnlist-loop-m2-run@1", runId, itemRef, graph, authorityRequired: Boolean(authority) } }); + syncDirectory(staging); + withDirectoryLock({ lockPath: currentLock, reclaimLiveAfterAge: false, errorFactory: () => fail("current Run binding is locked", "ELOCKED"), fn: () => { + if (existsSync(target)) fail("run already exists", "EEXIST"); + if (current) { + const entries = currentAuthority().read(), previous = entries.find((entry) => entry.itemRef === current.itemRef); + if (previous) { + if (previous.runId === runId && previous.assignmentId === current.assignmentId) { + // A cut after durable reservation but before directory rename is + // recovered only by the same sealed Run identity. + } else { + const prior = replay(previous.runId).projection; + if (!["failed", "stopped", "budget-exhausted", "needs-human"].includes(prior.state)) fail("current Run is still executable", "ECURRENT"); + currentAuthority().write([...entries.filter((entry) => entry.itemRef !== current.itemRef), { itemRef: current.itemRef, runId, assignmentId: current.assignmentId }]); + } + } else { + currentAuthority().write([...entries, { itemRef: current.itemRef, runId, assignmentId: current.assignmentId }]); + } + } + hooks.beforeRunPublish?.({ runId, staging, target }); renameSync(staging, target); syncDirectory(runs); + } }); + return replay(runId); + } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; } + } + // Publication is observational: commit and release the journal lock before notifying readers. + const published = (result) => { try { publishProjection(root, result); } catch {} return result; }; + return Object.freeze({ createRun: (...input) => published(createRun(...input)), replay, read: replay, + append: (...input) => published(append(...input)), acquireLease: (...input) => published(acquireLease(...input)), + releaseLease: (...input) => published(releaseLease(...input)), recoverLease: (...input) => published(recoverLease(...input)), + terminalize: (...input) => published(terminalize(...input)), list: () => { + if (!existsSync(runs)) return []; + const entries = readdirSync(runs, { withFileTypes: true }), staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)), visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); + if (staging.length > 128 || visible.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS"); + return visible.sort((a, b) => a.name.localeCompare(b.name)) + .map((entry) => replay(Buffer.from(entry.name, "hex").toString("utf8")).projection); + }, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, currentPath: join(base, "current-runs.json") }) }); +} diff --git a/src/loops/run/run-store.test.mjs b/src/loops/run/run-store.test.mjs new file mode 100644 index 00000000..81b0fd01 --- /dev/null +++ b/src/loops/run/run-store.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { runStore } from "./run-store.mjs"; +import { appendJournalRecord, createJournalRecord } from "./run-journal.mjs"; +import { testGraph, testRunId } from "./m2-test-fixtures.mjs"; +import { withDirectoryLock } from "../../server/dir-lock.mjs"; +import { readOvenEvents } from "../../events/oven-event-store.mjs"; +import { publishLoopProjectionInvalidation } from "../events/projection-events.mjs"; +import { presentRun } from "./read-projection.mjs"; + +function fixture(t) { const root = mkdtempSync(join(os.tmpdir(), "m2-store-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); return store; } +test("Run creation stages private authority and journal until one directory rename", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m6-stage-")); t.after(() => rmSync(root, { recursive: true, force: true })); let observed; + const store = runStore(root, { hooks: { beforeRunPublish({ runId, target }) { + observed = { listed: runStore(root).list(), target: existsSync(target), runId }; + } } }); + store.createRun({ runId: testRunId, itemRef: "item:260722-001#M6", graph: testGraph }); + assert.deepEqual(observed, { listed: [], target: false, runId: testRunId }); assert.equal(store.read(testRunId).journal.length, 1); + const failed = runStore(root, { hooks: { beforeRunPublish() { throw new Error("crash-before-publish"); } } }); + const other = "run:01arz3ndektsv4rrffq69g5faw"; + assert.throws(() => failed.createRun({ runId: other, itemRef: "item:260722-001#M6", graph: testGraph }), /crash-before-publish/u); + assert.equal(runStore(root).list().length, 1); +}); +test("authority-less fixture Runs freeze that fact and retain a null Loop revision", (t) => { + const store = fixture(t), current = store.read(testRunId); + assert.equal(current.journal[0].value.payload.authorityRequired, false); + assert.deepEqual(current.loopIdentity, { loopId: testGraph.id, loopRevision: null }); +}); +test("pre-fold rejects poison, and lost-owner proof fences without reader takeover", (t) => { + const store = fixture(t), acquired = store.acquireLease(testRunId), one = acquired.lease; + assert.throws(() => store.append(testRunId, one, "state-changed", { from: "running", to: "converged", cause: "graph" }), /bypass/u); + assert.equal(store.replay(testRunId).journal.length, 3); + assert.throws(() => store.acquireLease(testRunId), { code: "ELEASED" }); + assert.throws(() => store.recoverLease(testRunId, { generation: one.generation, recoveryProof: "reader-does-not-know" }), { code: "ELOST_PROOF" }); + assert.throws(() => store.acquireLease(testRunId), { code: "ELEASED" }); + store.recoverLease(testRunId, { generation: one.generation, recoveryProof: acquired.recoveryProof }); + assert.throws(() => store.append(testRunId, one, "node-started", { nodeId: "implement", attempt: 1 }), { code: "ESTALE_LEASE" }); + const two = store.acquireLease(testRunId).lease; assert.equal(two.generation, 2); +}); +test("an authorized recovery proof survives store recreation but is not replay data", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-recover-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; + const first = runStore(root, { clock: () => at++ }); first.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); const acquired = first.acquireLease(testRunId); + const second = runStore(root, { clock: () => at++ }); assert.equal(second.read(testRunId).projection.leaseHeld, true); + assert.throws(() => second.recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: "0".repeat(64) }), { code: "ELOST_PROOF" }); + second.recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }); const replacement = second.acquireLease(testRunId); assert.equal(replacement.lease.generation, 2); assert.doesNotMatch(JSON.stringify(second.read(testRunId)), new RegExp(acquired.recoveryProof, "u")); second.releaseLease(testRunId, replacement.lease); assert.equal(existsSync(join(second.paths.pathFor(testRunId), ".recovery-proof")), false); +}); +test("proof sidecar rejects malformed private state and acquisition cuts remain recoverable", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-proof-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; + const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); const acquired = store.acquireLease(testRunId), proof = join(store.paths.pathFor(testRunId), ".recovery-proof"); + chmodSync(proof, 0o644); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof is corrupt/u); + chmodSync(proof, 0o600); writeFileSync(proof, `${JSON.stringify({ schema: "burnlist-loop-m2-recovery-proof@1", runId: testRunId, generation: acquired.lease.generation, token: acquired.lease.token, recoveryProof: acquired.recoveryProof, extra: true })}\n`, { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof is corrupt/u); + writeFileSync(proof, `${JSON.stringify({ schema: "burnlist-loop-m2-recovery-proof@1", runId: testRunId, generation: acquired.lease.generation, token: "0".repeat(64), recoveryProof: acquired.recoveryProof })}\n`, { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /generation changed/u); + writeFileSync(proof, Buffer.alloc(1025), { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof is corrupt/u); + writeFileSync(proof, "{}\n", { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof is corrupt/u); + rmSync(proof); mkdirSync(proof, { mode: 0o600 }); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /proof/u); rmSync(proof, { recursive: true }); symlinkSync("missing", proof); assert.throws(() => runStore(root).recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }), /ELOOP|proof/u); rmSync(proof); + const cutRoot = mkdtempSync(join(os.tmpdir(), "m2-proof-cut-")); t.after(() => rmSync(cutRoot, { recursive: true, force: true })); const setup = runStore(cutRoot); setup.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); + assert.throws(() => runStore(cutRoot, { hooks: { beforeProofPublish() { throw new Error("before-proof"); } } }).acquireLease(testRunId), /before-proof/u); assert.equal(runStore(cutRoot).read(testRunId).projection.leaseHeld, false); + assert.throws(() => runStore(cutRoot, { hooks: { afterProofPublish() { throw new Error("after-proof"); } } }).acquireLease(testRunId), /after-proof/u); assert.equal(runStore(cutRoot).acquireLease(testRunId).lease.generation, 1); + const leaseRoot = mkdtempSync(join(os.tmpdir(), "m2-proof-lease-")); t.after(() => rmSync(leaseRoot, { recursive: true, force: true })); const leaseStore = runStore(leaseRoot); leaseStore.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); let captured; + assert.throws(() => runStore(leaseRoot, { hooks: { afterLeaseAppend(value) { captured = value; throw new Error("after-lease"); } } }).acquireLease(testRunId), /after-lease/u); const recreated = runStore(leaseRoot); recreated.recoverLease(testRunId, { generation: captured.lease.generation, recoveryProof: captured.recoveryProof }); assert.equal(recreated.acquireLease(testRunId).lease.generation, 2); +}); +test("lease acquire and release churn reserves one terminal record instead of stranding an owner", (t) => { + const store = fixture(t); let acquired = store.acquireLease(testRunId); + for (let cycle = 0; cycle < 126; cycle += 1) { store.releaseLease(testRunId, acquired.lease); acquired = store.acquireLease(testRunId); } + assert.equal(store.replay(testRunId).projection.sequence, 255); const terminal = store.releaseLease(testRunId, acquired.lease); + assert.equal(terminal.projection.sequence, 256); assert.equal(terminal.projection.state, "budget-exhausted"); assert.equal(terminal.projection.leaseHeld, false); assert.equal(terminal.journal.at(-1).value.type, "terminal-node-committed"); assert.equal(terminal.journal.some((record) => record.value.sequence === 257), false); +}); +function rawTerminal(store, type, payload) { const current = store.replay(testRunId), record = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at: current.journal.at(-1).value.at + 1, type, payload }); appendJournalRecord({ journalDirectory: store.paths.journalFor(testRunId), record }); return store.replay(testRunId); } +function ownerAt253(store) { let acquired = store.acquireLease(testRunId); for (let cycle = 0; cycle < 125; cycle += 1) { store.releaseLease(testRunId, acquired.lease); acquired = store.acquireLease(testRunId); } assert.equal(store.replay(testRunId).projection.sequence, 253); return acquired; } +test("a legacy stopped transition at 255 recovers through one lease-clearing final record", (t) => { + const store = fixture(t), acquired = ownerAt253(store); store.append(testRunId, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 }); + const legacy = rawTerminal(store, "state-changed", { from: "running", to: "stopped", cause: "control" }); assert.equal(legacy.projection.sequence, 255); assert.equal(legacy.projection.leaseHeld, true); + const cleaned = store.recoverLease(testRunId, { generation: acquired.lease.generation, recoveryProof: acquired.recoveryProof }); assert.equal(cleaned.projection.sequence, 256); assert.equal(cleaned.projection.state, "stopped"); assert.equal(cleaned.projection.leaseHeld, false); assert.equal(cleaned.journal.at(-1).value.type, "terminal-node-committed"); assert.equal(cleaned.journal.some((record) => record.value.sequence === 257), false); +}); +test("a legacy graph terminal at 255 releases through one lease-clearing final record", (t) => { + const store = fixture(t); let acquired = store.acquireLease(testRunId), lease = acquired.lease; + for (const [type, payload] of [["node-started", { nodeId: "implement", attempt: 1 }], ["invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }], ["invocation-result", { invocationId: "a".repeat(32), kind: "complete", summary: "ok", outputBytes: 0, candidateId: null }], ["edge-taken", { from: "implement", on: "complete", to: "verify" }], ["node-started", { nodeId: "verify", attempt: 1 }], ["invocation-started", { nodeId: "verify", attempt: 1, invocationId: "b".repeat(32) }], ["invocation-result", { invocationId: "b".repeat(32), kind: "pass", summary: "ok", outputBytes: 0, candidateId: null }], ["edge-taken", { from: "verify", on: "pass", to: "review" }], ["node-started", { nodeId: "review", attempt: 1 }], ["invocation-started", { nodeId: "review", attempt: 1, invocationId: "c".repeat(32) }], ["invocation-result", { invocationId: "c".repeat(32), kind: "approve", summary: "ok", outputBytes: 0, candidateId: null }], ["edge-taken", { from: "review", on: "approve", to: "converged" }], ["node-started", { nodeId: "converged", attempt: 1 }], ["edge-taken", { from: "converged", on: "pass", to: "completed" }]]) store.append(testRunId, lease, type, payload); + for (let cycle = 0; cycle < 118; cycle += 1) { store.releaseLease(testRunId, lease); acquired = store.acquireLease(testRunId); lease = acquired.lease; } assert.equal(store.replay(testRunId).projection.sequence, 253); + store.append(testRunId, lease, "node-started", { nodeId: "completed", attempt: 1 }); const legacy = rawTerminal(store, "state-changed", { from: "running", to: "converged", cause: "graph" }); assert.equal(legacy.projection.sequence, 255); assert.equal(legacy.projection.leaseHeld, true); + const cleaned = store.releaseLease(testRunId, lease); assert.equal(cleaned.projection.sequence, 256); assert.equal(cleaned.projection.state, "converged"); assert.equal(cleaned.projection.leaseHeld, false); assert.equal(cleaned.journal.at(-1).value.type, "terminal-node-committed"); assert.equal(cleaned.journal.some((record) => record.value.sequence === 257), false); +}); + +test("projection events publish after a committed revision, outside the journal lock, and deduplicate retries", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m7-run-events-")); t.after(() => rmSync(root, { recursive: true, force: true })); + let at = 0, store, lockWasFree = false; + const bootstrap = runStore(root, { clock: () => at++ }); + bootstrap.createRun({ runId: testRunId, itemRef: "item:260722-001#M7", graph: testGraph }); + store = runStore(root, { clock: () => at++, publishProjection(repo, replay) { + withDirectoryLock({ lockPath: join(store.paths.pathFor(testRunId), ".lock"), reclaimLiveAfterAge: false, + errorFactory: () => new Error("journal lock remained held during publication"), fn: () => { lockWasFree = true; } }); + const committed = store.replay(testRunId); + assert.equal(committed.revision, replay.revision); + assert.equal(presentRun(committed).revision, replay.revision, "event cursor uses the public projection revision"); + return publishLoopProjectionInvalidation(repo, replay); + } }); + const lease = store.acquireLease(testRunId).lease; + const result = store.append(testRunId, lease, "node-started", { nodeId: "implement", attempt: 1 }); + assert.equal(lockWasFree, true); + const events = readOvenEvents(root, { ovenIds: ["checklist"] }).filter((event) => event.kind === "loop-projection-changed"); + const event = events.at(-1); + assert.equal(event.cursor, result.revision); + assert.deepEqual(event.payload, { revision: result.revision }); + const retry = publishLoopProjectionInvalidation(root, result); + assert.equal(retry.created, false, "the same committed revision has one public event"); + assert.equal(readOvenEvents(root, { ovenIds: ["checklist"] }).filter((entry) => entry.cursor === result.revision).length, 1); +}); + +test("a projection publisher failure cannot roll back a committed run mutation", (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m7-run-publisher-failure-")); t.after(() => rmSync(root, { recursive: true, force: true })); + const store = runStore(root, { publishProjection() { throw new Error("event sink unavailable"); } }); + const created = store.createRun({ runId: testRunId, itemRef: "item:260722-001#M7", graph: testGraph }); + assert.equal(store.replay(testRunId).revision, created.revision); + assert.equal(store.list().length, 1); +}); diff --git a/src/loops/run/run-test-fixtures.mjs b/src/loops/run/run-test-fixtures.mjs new file mode 100644 index 00000000..c788b97a --- /dev/null +++ b/src/loops/run/run-test-fixtures.mjs @@ -0,0 +1,147 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { compileLoopPackage } from "../dsl/compile.mjs"; +import { freezeRecipe, loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { canonicalBoundPolicyBytes } from "./run-artifacts.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { canonicalCapabilityBytes, canonicalGrantBytes, capabilityRevision, GUARANTEE_LABELS } from "../capabilities/contract.mjs"; +import { snapshotTarget } from "../capabilities/snapshot.mjs"; +import { runStore } from "./run-store.mjs"; +import { createRunRunner } from "./runner.mjs"; +import { presentRun } from "./read-projection.mjs"; + +export const d = (prefix, char) => `${prefix}:${char.repeat(64)}`; +export const fixtureRunId = "run:01arz3ndektsv4rrffq69g5fav"; +export const fixtureItemRef = "item:260722-001#L29"; +export const m4ProgressOutcomes = ["complete", "pass", "reject", "complete", "pass", "approve"]; +let frozenPromise; +const fixtureBinary = "/bin/sh"; +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +export function frozenRecipeBytes() { + frozenPromise ??= compileLoopPackage(resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops/review")).then((compiled) => { + if (!compiled.ok) throw new Error("fixture Loop did not compile"); return freezeRecipe(compiled); + }); + return frozenPromise; +} +export function boundPolicyBytes(recipeBytes) { + const recipe = loadFrozenRecipe(recipeBytes), canonical = (value) => Buffer.from(`${JSON.stringify(value)}\n`); + const route = (id, routeName, authority, session, char) => { + const binary = fixtureBinary, model = "gpt-5.6-terra", effort = "medium", sandbox = authority === "write" ? "workspace-write" : "read-only"; + const profile = { schema: "burnlist-loop-agent-profile@1", id, adapter: "builtin:codex-cli", binary, model, effort, authority }; + const executableDigest = snapshotTarget({ root: "/", path: binary, maximum: 64 * 1024 * 1024 }).digest; + const profileRevision = prefixed("ap1-sha256:", "agent-profile-v1", [canonical(profile)]); + return { route: routeName, profile, profileRevision, executableDigest, + guarantees: routeName === "review.strong" + ? { freshSession: "enforced", filesystemWriteDeny: "supervised" } + : { freshSession: "enforced" } }; + }; + const policy = { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, + network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; + const grants = { argv: policy.argv, cwd: policy.cwd, environment: policy.environment, network: policy.network, + filesystem: policy.filesystem, output: policy.output, maxMilliseconds: policy.maxMilliseconds }; + const revision = capabilityRevision(policy), policyDigest = rawSha256(canonicalCapabilityBytes(policy)), grantsDigest = rawSha256(canonicalGrantBytes(grants, policy)); + return canonicalBoundPolicyBytes({ schema: "burnlist-loop-bound-policy@1", recipeRevision: recipe.revisions.executable, + routes: [route("maker", "implementation.standard", "write", "maker-session", "1"), route("reviewer", "review.strong", "read", "review-session", "2")], + capabilities: [{ id: "repo-verify", policy, revision, policyDigest, grants, grantsDigest, + trust: { schema: "burnlist-loop-capability-trust@1", capability: "repo-verify", revision, policyDigest, grants, grantsDigest }, guarantees: GUARANTEE_LABELS }] }); +} +export async function runInput(id = fixtureRunId) { + const recipe = await frozenRecipeBytes(); return { runId: id, assignmentId: d("as1-sha256", "a"), itemRef: "item:260722-001#L7", + itemRevision: d("id1-sha256", "b"), frozenRecipeBytes: recipe, policyBytes: boundPolicyBytes(recipe) }; +} + +export async function runM4ProgressFixture({ + repoRoot, + runId = fixtureRunId, + itemRef = fixtureItemRef, + outcomes = m4ProgressOutcomes, + graph, + clock, +}) { + if (!repoRoot) throw new Error("run fixture: repo root is required"); + const source = Array.isArray(outcomes) ? [...outcomes] : []; + const frozenGraph = graph ?? loadFrozenRecipe(await frozenRecipeBytes()).ir; + let at = 0; + const baseClock = clock ?? (() => at++); + const store = runStore(repoRoot, { clock: baseClock }); + store.createRun({ runId, itemRef, graph: frozenGraph }); + const runner = createRunRunner({ store, runId, invoke: async () => { + const outcome = source.shift(); + if (!outcome) throw new Error(`run fixture: missing outcome for ${runId}`); + return { kind: outcome, summary: outcome, outputBytes: 1 }; + }}); + const snapshots = []; + let previous = null; + const capture = () => { + const snapshot = presentRun(runner.replay()); + if ( + !previous + || snapshot.currentNode !== previous.currentNode + || snapshot.attempt !== previous.attempt + || snapshot.latestResult?.kind !== previous.latestResult?.kind + || snapshot.state !== previous.state + ) { + snapshots.push(snapshot); + previous = snapshot; + } + }; + for (let steps = 0; steps < 256; steps += 1) { + if (runner.replay().execution.terminal) break; + await runner.step(); + capture(); + } + const final = presentRun(runner.replay()); + if (!runner.replay().execution.terminal) throw new Error("run fixture: progress did not converge"); + if (!snapshots.length || snapshots.at(-1) !== final) snapshots.push(final); + return { store, runId, itemRef, graph: frozenGraph, transitions: final.transitions, final, snapshots }; +} + +function fixtureCapability() { + return { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", + environment: { inherit: ["PATH"], set: {} }, network: "deny", + filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; +} +function fixtureGrants(value) { + return { argv: value.argv, cwd: value.cwd, environment: value.environment, network: value.network, + filesystem: value.filesystem, output: value.output, maxMilliseconds: value.maxMilliseconds }; +} +function fixtureCommand(repo, ...args) { + const result = spawnSync(process.execPath, [resolve(projectRoot, "bin", "burnlist.mjs"), ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`fixture command ${args.join(" ")} failed: ${result.stderr}`); +} + +/** Build the same source/config/trust authority consumed by production createRun. */ +export function createProductionRunAuthority(repo) { + mkdirSync(repo, { recursive: true }); + const root = realpathSync(repo); + execFileSync("git", ["init", "--quiet", root]); mkdirSync(resolve(root, ".burnlist"), { recursive: true }); mkdirSync(resolve(root, "src")); + const plan = resolve(root, "notes", "burnlists", "inprogress", "260722-001"); mkdirSync(plan, { recursive: true }); + writeFileSync(resolve(plan, "burnlist.md"), "# Runner\n\n## Active Checklist\n- [ ] L29 | Exercise production authority\n\n## Completed\n"); + const binary = resolve(root, "fixtures", "codex"); mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(binary, `#!/usr/bin/env node +const fs=require("node:fs"),a=process.argv.slice(2),prompt=a.at(-1),lines=Object.fromEntries(prompt.split("\\n").filter(x=>x.includes("=")).map(x=>x.split(/=(.*)/s).slice(0,2))); +let outcome="complete";const counter=process.env.BURNLIST_FAKE_COUNTER; +if(process.env.BURNLIST_FAKE_STARTED){const marker=process.env.BURNLIST_FAKE_STARTED,tmp=marker+"."+process.pid+".tmp";fs.writeFileSync(tmp,JSON.stringify({pid:process.pid,node:lines.node,attempt:Number(lines.attempt)}));fs.renameSync(tmp,marker);} +const wait=Number(process.env.BURNLIST_FAKE_WAIT_MS||0);if(Number.isSafeInteger(wait)&&wait>0)Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,wait); +if(counter){const attempt=Number(lines.attempt),index=lines.node==="implement"?(attempt-1)*2:lines.node==="review"?(attempt-1)*2+1:Number(fs.readFileSync(counter,"utf8"));outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete").split(",")[index]||"approve";fs.writeFileSync(counter,String(Math.max(Number(fs.readFileSync(counter,"utf8")),index+1)));} +if(lines.node==="implement"&&outcome==="complete")fs.writeFileSync(process.cwd()+"/src/fake-maker-candidate.txt","maker-attempt="+lines.attempt+"\\n"); +const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome}; +const mode=process.env.BURNLIST_FAKE_FINAL_MODE;if(mode==="stale")final.inputCandidate="cm1-sha256:"+"f".repeat(64); +process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"s-"+process.pid,model:a[a.indexOf("-m")+1]})+"\\n"); +process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:mode==="malformed"?"not-json":JSON.stringify(final)}})+"\\n"); +process.stdout.write(JSON.stringify({type:"turn.completed",usage:{input_tokens:1,output_tokens:1,cached_input_tokens:0}})+"\\n"); +`); chmodSync(binary, 0o700); + const capability = fixtureCapability(), grantsPath = resolve(root, "grants.json"); + writeFileSync(resolve(root, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [capability] })}\n`); + writeFileSync(grantsPath, `${JSON.stringify(fixtureGrants(capability))}\n`); + for (const [id, authority, route] of [["maker", "write", "implementation.standard"], ["reviewer", "read", "review.strong"]]) { + fixtureCommand(root, "agent", "profile", "add", id, "--adapter", "builtin:codex-cli", "--binary", binary, + "--model", "gpt-5.3-codex-spark", "--effort", "medium", "--authority", authority); + fixtureCommand(root, "route", "set", route, "--profile", id); + } + fixtureCommand(root, "loop", "capability", "trust", "repo-verify", "--revision", capabilityRevision(capability), "--grants", grantsPath); + fixtureCommand(root, "loop", "assign", fixtureItemRef, "loop:builtin:review"); + return { repo: root, binary }; +} diff --git a/src/loops/run/runner.mjs b/src/loops/run/runner.mjs new file mode 100644 index 00000000..db6e618b --- /dev/null +++ b/src/loops/run/runner.mjs @@ -0,0 +1,87 @@ +import { randomBytes } from "node:crypto"; +import { budgetReason } from "./budgets.mjs"; +import { gateDecision } from "./state-machine.mjs"; +import { isSystemOutcome, validateNormalizedResult } from "./run-result.mjs"; + +const fail = (message) => { throw Object.assign(new Error(`Run runner: ${message}`), { code: "ERUNNER" }); }; +function boundedSummary(value) { + const bytes = Buffer.from(String(value ?? "candidate capture failed"), "utf8"); + if (bytes.length <= 1024) return bytes.toString("utf8"); + let end = 1024; + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; + return bytes.subarray(0, end).toString("utf8"); +} +export function createRunRunner({ store, runId, invoke, bindCandidate = null }) { + if (!store?.replay || !store?.append || !store?.acquireLease || !store?.terminalize || typeof invoke !== "function") fail("invalid runner input"); + let lease = null, pauseRequested = false, stopRequested = false, cancelRequested = false, cancelWake = null; + const read = () => store.replay(runId), append = (type, payload) => store.append(runId, lease, type, payload); + function transition(to, cause) { const execution = read().execution; return append("state-changed", { from: execution.state, to, cause }); } + function edge(execution, on) { const current = read(), target = current.graph.edges.find((item) => item.from === execution.nodeId && item.on === on); if (!target) fail("outcome has no declared edge"); const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, edge: target }); if (exhausted) return system("exhausted", exhausted); return append("edge-taken", { from: target.from, on, to: target.to }); } + function system(kind, summary) { return append("system-outcome", { kind, summary }); } + function routeSystem(current, execution) { const target = current.graph.failurePolicy[execution.system.kind]; if (execution.nodeId !== target) return append("failure-routed", { from: execution.nodeId, kind: execution.system.kind, to: target }); if (!execution.started) return append("node-started", { nodeId: target, attempt: execution.attempt + 1 }); return transition(execution.node.state, "graph"); } + async function step() { + let current = read(), execution = current.execution; if (execution.terminal) return { kind: "terminal", state: execution.state }; if (!lease) lease = store.acquireLease(runId).lease; + current = read(); execution = current.execution; if (execution.terminal) { lease = null; return { kind: "terminal", state: execution.state }; } if (execution.system) return routeSystem(current, execution); const node = execution.node; if (execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return system("exhausted", "minutes"); + if (!execution.started) { const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, node }); if (exhausted) return system("exhausted", exhausted); return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); } + if (node.kind === "terminal") return transition(node.state, "graph"); + if (node.kind === "gate") return edge(execution, gateDecision(execution, current.graph)); + if (execution.result) { + if (node.kind === "agent" && node.mode === "task" && execution.result.kind === "complete" && !execution.candidate && typeof bindCandidate === "function") { + try { + const candidate = bindCandidate({ runId, nodeId: node.id, attempt: execution.attempt, result: execution.result }); + return append("candidate-bound", candidate); + } catch (error) { + return system("error", boundedSummary(error?.message)); + } + } + return edge(execution, execution.result.kind); + } + if (execution.invocation) return system("lost", "persisted invocation requires recovery"); + append("invocation-started", { nodeId: node.id, attempt: execution.attempt, invocationId: randomBytes(16).toString("hex") }); current = read(); execution = current.execution; + const pending = Promise.resolve(invoke(Object.freeze({ runId, nodeId: node.id, attempt: execution.attempt, invocationId: execution.invocation.invocationId }))) + .then((value) => ({ value }), (error) => ({ error })); + let result; + const raced = cancelRequested ? { cancelled: true } : await Promise.race([pending, new Promise((resolve) => { cancelWake = () => resolve({ cancelled: true }); })]); + cancelWake = null; + if (raced?.cancelled || cancelRequested || pauseRequested || stopRequested) { + invoke.cancel?.(); + // A control request fences the Run until the real foreground handle has + // settled. Never manufacture a cancellation result: releasing a lease + // while a child may still write would permit a split-brain resume. + const settled = await pending; + result = settled.error ? { kind: "error", summary: String(settled.error?.message ?? "invocation error"), outputBytes: 0 } : settled.value; + } else result = raced.error ? { kind: "error", summary: String(raced.error?.message ?? "invocation error"), outputBytes: 0 } : raced.value; + // A pause is committed only after the foreground handle has settled. Its + // cancelled result is intentionally not journalled: it has no graph edge, + // and the next foreground owner must retry this unfinished invocation. + if (pauseRequested && !stopRequested) return { kind: "paused" }; + result = validateNormalizedResult({ ...result, candidateId: result.candidateId ?? execution.candidate?.id ?? null }, node, current.graph.budget.maxOutputBytes); const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, outputBytes: result.outputBytes }); + return append("invocation-result", { invocationId: execution.invocation.invocationId, ...(exhausted ? { kind: "exhausted", summary: exhausted, outputBytes: 0, candidateId: execution.candidate?.id ?? null } : result) }); + } + function pause() { + const current = read(); + if (current.execution.terminal || current.execution.state === "paused") return current; + if (!lease || current.execution.state !== "running" || current.execution.invocation && !current.execution.result && !pauseRequested) + fail("pause requires an idle foreground lease"); + append("state-changed", { from: "running", to: "paused", cause: "control" }); + store.releaseLease(runId, lease); lease = null; return read(); + } + function stop() { + const current = read(); if (current.execution.terminal) return current; + if (!lease) lease = store.acquireLease(runId).lease; + const result = store.terminalize(runId, lease, "cancelled", "control"); lease = null; return result; + } + async function run() { for (;;) { + if (stopRequested) return stop(); + await step(); + // A second SIGINT upgrades a pending pause to stop while the invocation + // settles. Check it before the pause branch so terminal control wins. + if (stopRequested) return stop(); + const current = read(); + if (current.execution.terminal) { if (lease && current.execution.lease) store.releaseLease(runId, lease); lease = null; return read(); } + if (pauseRequested) return pause(); + } } + function requestPause() { pauseRequested = true; cancelRequested = true; invoke.cancel?.(); cancelWake?.(); } + function requestStop() { stopRequested = true; cancelRequested = true; invoke.cancel?.(); cancelWake?.(); } + return Object.freeze({ step, run, pause, stop, requestPause, requestStop, replay: read, get lease() { return lease; } }); +} diff --git a/src/loops/run/runner.test.mjs b/src/loops/run/runner.test.mjs new file mode 100644 index 00000000..b0af07a5 --- /dev/null +++ b/src/loops/run/runner.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createRunRunner } from "./runner.mjs"; +import { gateDecision } from "./state-machine.mjs"; +import { normalizeIr } from "../dsl/canonical.mjs"; +import { outcomesFor } from "../dsl/grammar.mjs"; +import { runStore } from "./run-store.mjs"; +import { testGraph, testRunId } from "./m2-test-fixtures.mjs"; + +function fixture(t, graph = testGraph) { const root = mkdtempSync(join(os.tmpdir(), "m2-runner-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph }); return store; } +function renamedGraph() { const names = { verify: "audit", review: "tests" }, graph = JSON.parse(JSON.stringify(testGraph)); graph.nodes = graph.nodes.map((node) => node.kind === "agent" ? { ...node, id: names[node.id] ?? node.id, independentFrom: node.independentFrom ? names[node.independentFrom] ?? node.independentFrom : null } : node.kind === "check" ? { ...node, id: names[node.id] ?? node.id } : node.kind === "gate" ? { ...node, requires: node.requires.map((id) => names[id] ?? id) } : node); graph.edges = graph.edges.map((edge) => ({ ...edge, from: names[edge.from] ?? edge.from, to: names[edge.to] ?? edge.to })); return normalizeIr(graph, outcomesFor); } +function highBoundaryGraph() { const graph = JSON.parse(JSON.stringify(testGraph)); graph.budget = { maxRounds: 100, maxMinutes: 1000, maxAgentRuns: 100, maxCheckRuns: 100, maxTransitions: 1000, maxOutputBytes: 1000000 }; graph.edges = graph.edges.map((edge) => edge.from === "review" && edge.on === "reject" || edge.from === "verify" && edge.on === "fail" ? { ...edge, maxVisits: 100 } : edge); return normalizeIr(graph, outcomesFor); } +function ownerAt253(store) { let acquired = store.acquireLease(testRunId); for (let cycle = 0; cycle < 125; cycle += 1) { store.releaseLease(testRunId, acquired.lease); acquired = store.acquireLease(testRunId); } assert.equal(store.replay(testRunId).projection.sequence, 253); return acquired; } +test("runner table traverses maker/check/reviewer/gate/terminal", async (t) => { + const store = fixture(t), outcomes = ["complete", "pass", "approve"], calls = []; + const runner = createRunRunner({ store, runId: testRunId, clock: () => 0, invoke: async ({ nodeId }) => { calls.push(nodeId); return { kind: outcomes.shift(), summary: "ok", outputBytes: 1 }; } }); + assert.equal((await runner.run()).projection.state, "converged"); assert.deepEqual(calls, ["implement", "verify", "review"]); +}); +test("post-maker candidate capture failure journals a restart-safe terminal and releases its lease", async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m12-candidate-failure-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const store = runStore(root, { clock: (() => { let at = 0; return () => at++; })() }); + store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); + const runner = createRunRunner({ store, runId: testRunId, + invoke: async () => ({ kind: "complete", summary: "maker complete", outputBytes: 0 }), + bindCandidate() { throw new Error("candidate manifest exceeds bounds"); } }); + const result = await runner.run(); + assert.equal(result.projection.state, "failed"); + assert.equal(result.projection.leaseHeld, false); + assert.equal(result.execution.system.summary, "candidate manifest exceeds bounds"); + const replayed = runStore(root).read(testRunId); + assert.equal(replayed.projection.state, "failed"); + assert.equal(replayed.projection.leaseHeld, false); + assert.equal(replayed.journal.some((record) => record.value.type === "system-outcome"), true); +}); +test("multibyte candidate capture errors are UTF-8 bounded before durable terminalization", async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m12-candidate-utf8-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const store = runStore(root); + store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); + const runner = createRunRunner({ store, runId: testRunId, + invoke: async () => ({ kind: "complete", summary: "maker complete", outputBytes: 0 }), + bindCandidate() { throw new Error("💥".repeat(400)); } }); + const result = await runner.run(); + assert.equal(result.projection.state, "failed"); + assert.equal(result.projection.leaseHeld, false); + assert.ok(Buffer.byteLength(result.execution.system.summary, "utf8") <= 1024); + const replayed = runStore(root).read(testRunId); + assert.equal(replayed.projection.state, "failed"); + assert.equal(replayed.projection.leaseHeld, false); +}); +test("renamed audit/tests requirements drive gate convergence", async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-renamed-")); t.after(() => rmSync(root, { recursive: true, force: true })); let at = 0; const store = runStore(root, { clock: () => at++ }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: renamedGraph() }); + const runner = createRunRunner({ store, runId: testRunId, invoke: async ({ nodeId }) => ({ kind: nodeId === "implement" ? "complete" : nodeId === "audit" ? "pass" : "approve", summary: "ok", outputBytes: 0 }) }); + assert.equal((await runner.run()).projection.state, "converged"); +}); +test("persisted time, not a runner clock, decides exact budget exhaustion", async (t) => { + const root = mkdtempSync(join(os.tmpdir(), "m2-time-")); t.after(() => rmSync(root, { recursive: true, force: true })); const boundary = testGraph.budget.maxMinutes * 60_000, times = [0, boundary, boundary, boundary]; + const store = runStore(root, { clock: () => times.shift() ?? boundary }); store.createRun({ runId: testRunId, itemRef: "item:260722-001#M2", graph: testGraph }); + const runner = createRunRunner({ store, runId: testRunId, clock: () => Number.MAX_SAFE_INTEGER, invoke: async () => ({ kind: "complete", summary: "no", outputBytes: 0 }) }); + await runner.run(); assert.equal(store.replay(testRunId).projection.state, "budget-exhausted"); +}); +test("every system result restarts through its declared failure terminal without reinvocation", async (t) => { + for (const kind of ["error", "timeout", "cancelled", "lost", "exhausted"]) { + const store = fixture(t); let calls = 0, runner = createRunRunner({ store, runId: testRunId, invoke: async () => { calls += 1; return { kind, summary: kind, outputBytes: 0 }; } }); + await runner.step(); await runner.step(); assert.equal(calls, 1); assert.equal(store.replay(testRunId).execution.system.kind, kind); store.releaseLease(testRunId, runner.lease); + runner = createRunRunner({ store, runId: testRunId, invoke: async () => { calls += 1; throw new Error("duplicate invocation"); } }); await runner.step(); const routed = store.replay(testRunId), target = testGraph.failurePolicy[kind]; assert.equal(routed.projection.currentNode, target); assert.deepEqual(routed.journal.at(-1).value, { schema: "burnlist-loop-m2-journal@1", sequence: routed.projection.sequence, prevDigest: routed.journal.at(-2).digest, at: routed.journal.at(-1).value.at, type: "failure-routed", payload: { from: "implement", kind, to: target } }); store.releaseLease(testRunId, runner.lease); + runner = createRunRunner({ store, runId: testRunId, invoke: async () => { calls += 1; throw new Error("duplicate invocation"); } }); await runner.step(); const started = store.replay(testRunId); assert.equal(started.journal.at(-1).value.type, "node-started"); assert.equal(started.journal.at(-1).value.payload.nodeId, target); store.releaseLease(testRunId, runner.lease); + runner = createRunRunner({ store, runId: testRunId, invoke: async () => { calls += 1; throw new Error("duplicate invocation"); } }); const result = await runner.run(), terminalNode = testGraph.nodes.find((node) => node.id === target); assert.equal(result.projection.state, terminalNode.state); assert.equal(result.projection.currentNode, target); assert.equal(result.projection.leaseHeld, false); assert.equal(result.execution.system.kind, kind); assert.equal(calls, 1); assert.equal(result.journal.filter((record) => record.value.type === "invocation-started").length, 1); assert.equal(result.journal.filter((record) => record.value.type === "node-started" && record.value.payload.nodeId === target).length, 1); + } +}); +test("system outcomes near terminal capacity restart to target terminal node", async (t) => { + const store = fixture(t), acquired = ownerAt253(store); + store.append(testRunId, acquired.lease, "system-outcome", { kind: "error", summary: "boom" }); + store.releaseLease(testRunId, acquired.lease); + const runner = createRunRunner({ store, runId: testRunId, invoke: async () => { throw new Error("should not execute"); } }); + const final = await runner.run(); + const last = final.journal.at(-1).value.payload; + assert.equal(final.projection.sequence, 256); + assert.equal(final.projection.state, "failed"); + assert.equal(final.projection.currentNode, "failed"); + assert.equal(final.projection.leaseHeld, false); + assert.equal(final.journal.at(-1).value.type, "terminal-node-committed"); + assert.equal(last.nodeId, "failed"); + assert.equal(last.to, "failed"); + assert.equal(last.attempt, 1); + assert.equal(final.journal.some((record) => record.value.sequence === 257), false); +}); +test("check fail and reviewer reject consume repair cycles before escalation", async (t) => { + const store = fixture(t), outcomes = ["complete", "fail", "complete", "pass", "reject", "complete", "pass", "escalate"]; + const runner = createRunRunner({ store, runId: testRunId, invoke: async () => ({ kind: outcomes.shift(), summary: "branch", outputBytes: 0 }) }); + const result = await runner.run(); assert.equal(result.projection.state, "needs-human"); assert.equal(result.execution.cycle, 3); assert.equal(result.execution.attempts.implement, 3); +}); +test("gate fail is derived when its current required evidence is absent", () => { + const gate = testGraph.nodes.find((node) => node.kind === "gate"); + assert.equal(gateDecision({ node: gate, cycle: 1, evidence: { verify: { kind: "pass", cycle: 1 } } }, testGraph), "fail"); +}); +test("recovery fences a persisted invocation and never re-invokes it", async (t) => { + const store = fixture(t), acquired = store.acquireLease(testRunId), lease = acquired.lease; + store.append(testRunId, lease, "node-started", { nodeId: "implement", attempt: 1 }); store.append(testRunId, lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); store.recoverLease(testRunId, { generation: lease.generation, recoveryProof: acquired.recoveryProof }); + let calls = 0; const runner = createRunRunner({ store, runId: testRunId, clock: () => 0, invoke: async () => { calls += 1; return { kind: "complete", summary: "bad", outputBytes: 0 }; } }); + await runner.run(); assert.equal(calls, 0); assert.equal(store.replay(testRunId).projection.state, "needs-human"); assert.equal(store.replay(testRunId).projection.currentNode, "needs-human"); +}); +test("an unsettled cancellation retains its lease; a second interrupt stops only after settlement", async (t) => { + const store = fixture(t); let settle; + const invoke = Object.assign(() => new Promise((resolve) => { settle = resolve; }), { cancel: () => true }); + const runner = createRunRunner({ store, runId: testRunId, invoke }), running = runner.run(); + while (!settle) await new Promise((resolve) => setImmediate(resolve)); + runner.requestPause(); await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(store.read(testRunId).projection.state, "running"); assert.equal(store.read(testRunId).projection.leaseHeld, true); + runner.requestStop(); settle({ kind: "cancelled", summary: "settled", outputBytes: 0 }); + assert.equal((await running).projection.state, "stopped"); assert.equal(store.read(testRunId).projection.leaseHeld, false); +}); +test("a settled foreground interruption pauses without a synthetic result and resumes the unfinished node", async (t) => { + const store = fixture(t); let settle; + const interruptedInvoke = Object.assign(() => new Promise((resolve) => { settle = resolve; }), { cancel: () => true }); + const interrupted = createRunRunner({ store, runId: testRunId, invoke: interruptedInvoke }); + const running = interrupted.run(); while (!settle) await new Promise((resolve) => setImmediate(resolve)); + interrupted.requestPause(); settle({ kind: "cancelled", summary: "interrupted", outputBytes: 0 }); + assert.equal((await running).projection.state, "paused"); + const paused = store.replay(testRunId); + assert.equal(paused.execution.invocation, null); assert.equal(paused.execution.result, null); + assert.equal(paused.journal.filter((record) => record.value.type === "invocation-result").length, 0); + const resumed = createRunRunner({ store, runId: testRunId, invoke: async ({ nodeId }) => ({ + kind: nodeId === "implement" ? "complete" : nodeId === "verify" ? "pass" : "approve", summary: "resumed", outputBytes: 0, + }) }); + const completed = await resumed.run(); + assert.equal(completed.projection.state, "converged"); + assert.equal(completed.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement").length, 2); +}); +test("ordinary retry traffic spends the final slot on one lease-clearing journal terminal", async (t) => { + const store = fixture(t, highBoundaryGraph()), calls = []; + const runner = createRunRunner({ store, runId: testRunId, invoke: async ({ nodeId }) => { calls.push(nodeId); return { kind: nodeId === "implement" ? "complete" : "fail", summary: "retry", outputBytes: 0 }; } }); + const result = await runner.run(), replay = store.replay(testRunId); + assert.equal(result.projection.sequence, 256); assert.equal(result.projection.state, "budget-exhausted"); assert.equal(result.projection.leaseHeld, false); assert.equal(result.projection.journal.remaining, 0); assert.equal(replay.journal.at(-1).value.type, "terminal-node-committed"); assert.equal(replay.journal.some((record) => record.value.sequence === 257), false); assert.ok(calls.filter((nodeId) => nodeId === "verify").length > 20); +}); diff --git a/src/loops/run/state-machine.mjs b/src/loops/run/state-machine.mjs new file mode 100644 index 00000000..3457040a --- /dev/null +++ b/src/loops/run/state-machine.mjs @@ -0,0 +1,70 @@ +import { validateClosedIr } from "../dsl/ir-validate.mjs"; +import { isRunRef } from "./run-ref.mjs"; +import { foldBudgets } from "./budgets.mjs"; +import { isSystemOutcome, validateNormalizedResult } from "./run-result.mjs"; + +const TERMINAL = new Set(["converged", "needs-human", "failed", "stopped", "budget-exhausted"]), SYSTEM = { error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" }, ATOMIC = { ...SYSTEM, converged: "converged" }; +const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +const fail = (message) => { throw Object.assign(new Error(`Run state machine: ${message}`), { code: "ESTATE" }); }; +export const isTerminalState = (state) => TERMINAL.has(state); +export const systemState = (kind) => SYSTEM[kind] ?? fail("unknown system outcome"); +export const atomicTerminalState = (kind) => ATOMIC[kind] ?? fail("unknown atomic terminal"); +export function validateGraph(graph) { if (!validateClosedIr(graph)) fail("graph is not canonical closed IR"); return Object.freeze({ nodes: new Map(graph.nodes.map((node) => [node.id, node])), edges: new Map(graph.edges.map((edge) => [`${edge.from}\0${edge.on}`, edge])) }); } +export function validateStateTransition(from, to, cause) { + if (!(["control", "graph", "system"].includes(cause))) fail("invalid transition cause"); + const control = (from === "prepared" && ["running", "stopped"].includes(to)) || (from === "running" && ["paused", "stopped"].includes(to)) || (from === "paused" && ["running", "stopped"].includes(to)); + if (cause === "control" && control) return { from, to, cause }; + if (cause === "graph" && from === "running" && TERMINAL.has(to)) return { from, to, cause }; + if (cause === "system" && from === "running" && TERMINAL.has(to)) return { from, to, cause }; + fail("closed lifecycle transition rejected"); +} +function gateOutcome(runtime, node) { + const valid = node.requires.every((id) => { + const evidence = runtime.evidence[id], required = runtime.nodes.get(id); + return evidence?.cycle === runtime.cycle && evidence.candidateId === (runtime.candidate?.id ?? null) + && (required?.kind === "check" ? evidence.kind === "pass" : required?.kind === "agent" && required.mode === "review" && evidence.kind === "approve"); + }); + return valid ? "pass" : "fail"; +} +export function foldStateMachine({ graph, records }) { + const { nodes, edges } = validateGraph(graph), first = records[0]?.value?.payload; + if (!first || first.type !== undefined && records[0].value.type !== "run-created" || !isRunRef(first.runId)) fail("invalid RunRef creation"); + const current = { state: "prepared", generation: 0, lease: null }, runtime = { nodeId: graph.entry, attempts: {}, started: false, invocation: null, result: null, system: null, cycle: 0, evidence: {}, candidate: null, latest: { maker: null, check: null, reviewer: null }, nodes, edges }; + for (const [index, record] of records.entries()) { + const { type, payload } = record.value, node = nodes.get(runtime.nodeId); if (!index) continue; + if (type === "state-changed") { if (!exact(payload, ["from", "to", "cause"]) || payload.from !== current.state) fail("invalid state event"); validateStateTransition(payload.from, payload.to, payload.cause); if (payload.cause === "control" && payload.to === "paused" && runtime.invocation && !runtime.result) runtime.invocation = null; if (payload.cause === "graph" && (!node || node.kind !== "terminal" || !runtime.started || payload.to !== node.state)) fail("graph terminal bypass"); if (payload.cause === "system" && (!runtime.system || payload.to !== systemState(runtime.system.kind))) fail("system terminal bypass"); current.state = payload.to; continue; } + if (type === "lease-acquired") { if (!exact(payload, ["generation", "token"]) || current.state !== "running" || current.lease || payload.generation !== current.generation + 1 || !/^[a-f0-9]{64}$/u.test(payload.token)) fail("invalid lease acquisition"); current.generation = payload.generation; current.lease = payload; continue; } + if (type === "lease-released" || type === "lease-revoked") { if (!exact(payload, ["generation", "token"]) || !current.lease || payload.generation !== current.lease.generation || payload.token !== current.lease.token) fail("stale lease change"); current.lease = null; continue; } + if (type === "terminal-node-committed") { const cleanup = isTerminalState(current.state), targetState = atomicTerminalState(payload.kind), expectedState = cleanup ? current.state : targetState, expectedNode = payload.kind === "converged" ? graph.nodes.find((item) => item.kind === "terminal" && item.state === "converged")?.id : graph.failurePolicy[payload.kind], targetNode = nodes.get(payload.nodeId), alreadyStarted = runtime.nodeId === payload.nodeId && runtime.started, expectedAttempt = alreadyStarted ? runtime.attempts[payload.nodeId] : (runtime.attempts[payload.nodeId] ?? 0) + 1; if (!exact(payload, ["kind", "summary", "from", "to", "nodeId", "attempt"]) || payload.from !== current.state || !cleanup && !["prepared", "paused", "running"].includes(payload.from) || cleanup && targetState !== current.state || payload.to !== expectedState || payload.nodeId !== expectedNode || targetNode?.kind !== "terminal" || targetNode.state !== payload.to || payload.attempt !== expectedAttempt || typeof payload.summary !== "string" || Buffer.byteLength(payload.summary, "utf8") > 1024 || runtime.system && (runtime.system.kind !== payload.kind || runtime.system.summary !== payload.summary)) fail("invalid atomic terminal node"); runtime.system ??= Object.freeze({ kind: payload.kind, summary: payload.summary, outputBytes: 0 }); if (!alreadyStarted) { runtime.nodeId = payload.nodeId; runtime.started = true; runtime.invocation = null; runtime.result = null; runtime.attempts[payload.nodeId] = payload.attempt; } current.lease = null; current.state = payload.to; continue; } + if (!current.lease || current.state !== "running") fail("active event lacks lease"); + if (type === "node-started") { if (!exact(payload, ["nodeId", "attempt"]) || payload.nodeId !== runtime.nodeId || runtime.started || payload.attempt !== (runtime.attempts[payload.nodeId] ?? 0) + 1) fail("invalid node start"); runtime.started = true; runtime.attempts[payload.nodeId] = payload.attempt; if (node.kind === "agent" && node.mode === "task") { runtime.cycle += 1; runtime.candidate = null; } continue; } + if (type === "invocation-started") { if (!exact(payload, ["nodeId", "attempt", "invocationId"]) || !runtime.started || runtime.invocation || !["agent", "check"].includes(node.kind) || payload.nodeId !== runtime.nodeId || payload.attempt !== runtime.attempts[payload.nodeId] || !/^[a-f0-9]{32}$/u.test(payload.invocationId)) fail("invalid invocation start"); runtime.invocation = payload; continue; } + if (type === "invocation-result") { + if (!runtime.invocation || runtime.result || !exact(payload, ["invocationId", "kind", "summary", "outputBytes", "candidateId"]) || payload.invocationId !== runtime.invocation.invocationId) fail("invalid invocation result"); + runtime.result = validateNormalizedResult({ kind: payload.kind, summary: payload.summary, outputBytes: payload.outputBytes, candidateId: payload.candidateId }, node, graph.budget.maxOutputBytes); + const role = node.kind === "check" ? "check" : node.mode === "review" ? "reviewer" : "maker"; + if (node.kind !== "agent" || node.mode !== "task") { + if (payload.candidateId !== (runtime.candidate?.id ?? null)) fail("result is not bound to the current candidate"); + } else if (payload.candidateId !== null && payload.candidateId !== (runtime.candidate?.id ?? null)) fail("maker result candidate is invalid"); + runtime.latest[role] = { summary: runtime.result.summary, at: record.value.at, candidateId: runtime.result.candidateId }; + if (isSystemOutcome(runtime.result.kind)) runtime.system = runtime.result; + else runtime.evidence[runtime.nodeId] = { kind: runtime.result.kind, cycle: runtime.cycle, candidateId: runtime.result.candidateId }; + continue; + } + if (type === "candidate-bound") { + if (!exact(payload, ["candidateId", "candidateContext"]) || node?.kind !== "agent" || node.mode !== "task" || !runtime.result || runtime.result.kind !== "complete" || runtime.candidate + || !/^cm1-sha256:[a-f0-9]{64}$/u.test(payload.candidateId) || typeof payload.candidateContext !== "string" || !payload.candidateContext || Buffer.byteLength(payload.candidateContext, "utf8") > 65_536) fail("invalid candidate binding"); + runtime.candidate = Object.freeze({ id: payload.candidateId, context: payload.candidateContext }); + runtime.latest.maker = { summary: runtime.result.summary, at: record.value.at, candidateId: payload.candidateId }; + runtime.evidence[runtime.nodeId] = { kind: runtime.result.kind, cycle: runtime.cycle, candidateId: payload.candidateId }; + continue; + } + if (type === "system-outcome") { if (runtime.system || !exact(payload, ["kind", "summary"]) || !isSystemOutcome(payload.kind) || typeof payload.summary !== "string" || Buffer.byteLength(payload.summary, "utf8") > 1024) fail("invalid system outcome"); runtime.system = Object.freeze({ kind: payload.kind, summary: payload.summary, outputBytes: 0 }); continue; } + if (type === "failure-routed") { const target = nodes.get(payload?.to); if (!runtime.system || !exact(payload, ["from", "kind", "to"]) || payload.from !== runtime.nodeId || payload.kind !== runtime.system.kind || payload.to !== graph.failurePolicy[payload.kind] || payload.to === runtime.nodeId || target?.kind !== "terminal" || target.state !== systemState(payload.kind)) fail("invalid failure route"); runtime.nodeId = payload.to; runtime.started = false; runtime.invocation = null; runtime.result = null; continue; } + if (type === "edge-taken") { if (!exact(payload, ["from", "on", "to"]) || payload.from !== runtime.nodeId) fail("invalid edge event"); const edge = edges.get(`${payload.from}\0${payload.on}`); if (!edge || edge.to !== payload.to) fail("undeclared edge"); if (node.kind === "gate" ? payload.on !== gateOutcome(runtime, node) : !runtime.result || isSystemOutcome(runtime.result.kind) || payload.on !== runtime.result.kind) fail("edge outcome is not current result"); runtime.nodeId = edge.to; runtime.started = false; runtime.invocation = null; runtime.result = null; continue; } + fail("unknown event"); + } + const budget = foldBudgets({ records, graph }); + return Object.freeze({ state: current.state, generation: current.generation, lease: current.lease && Object.freeze({ ...current.lease }), nodeId: runtime.nodeId, node: nodes.get(runtime.nodeId), attempts: Object.freeze({ ...runtime.attempts }), attempt: runtime.attempts[runtime.nodeId] ?? 0, cycle: runtime.cycle, evidence: Object.freeze({ ...runtime.evidence }), candidate: runtime.candidate, latest: Object.freeze({ ...runtime.latest }), started: runtime.started, invocation: runtime.invocation && Object.freeze({ ...runtime.invocation }), result: runtime.result, system: runtime.system, budget, terminal: isTerminalState(current.state) }); +} +export function gateDecision(execution, graph) { return gateOutcome({ evidence: execution.evidence, candidate: execution.candidate, cycle: execution.cycle, nodes: new Map(graph.nodes.map((node) => [node.id, node])) }, execution.node); } diff --git a/src/loops/run/state-machine.test.mjs b/src/loops/run/state-machine.test.mjs new file mode 100644 index 00000000..d933c88d --- /dev/null +++ b/src/loops/run/state-machine.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJournalRecord } from "./run-journal.mjs"; +import { foldRun } from "./run-fold.mjs"; +import { foldStateMachine, validateGraph } from "./state-machine.mjs"; +import { validateNormalizedResult } from "./run-result.mjs"; +import { created, testGraph } from "./m2-test-fixtures.mjs"; + +const append = (records, type, payload) => [...records, createJournalRecord({ sequence: records.length + 1, prevDigest: records.at(-1)?.digest ?? null, at: records.length, type, payload })]; +test("table validates every executable outcome and rejects cross-node outcomes", () => { + const nodes = new Map(testGraph.nodes.map((node) => [node.id, node])); + for (const [node, outcomes] of [["implement", ["complete"]], ["verify", ["pass", "fail"]], ["review", ["approve", "reject", "escalate"]]]) for (const outcome of outcomes) assert.doesNotThrow(() => validateNormalizedResult({ kind: outcome, summary: "ok", outputBytes: 0, candidateId: null }, nodes.get(node), testGraph.budget.maxOutputBytes)); + assert.throws(() => validateNormalizedResult({ kind: "approve", summary: "ok", outputBytes: 0, candidateId: null }, nodes.get("implement"), testGraph.budget.maxOutputBytes), /legal/u); + assert.throws(() => validateGraph({ ...testGraph, ignored: true }), /canonical/u); +}); + +test("a semantic result may retain its evidence when a later limit selects exhaustion", () => { + let records = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; + records = append(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); + records = append(records, "lease-acquired", { generation: 1, token: "a".repeat(64) }); + records = append(records, "node-started", { nodeId: "implement", attempt: 1 }); + records = append(records, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "b".repeat(32) }); + records = append(records, "invocation-result", { invocationId: "b".repeat(32), kind: "complete", summary: "ok", outputBytes: 0, candidateId: null }); + records = append(records, "system-outcome", { kind: "exhausted", summary: "transitions" }); + records = append(records, "state-changed", { from: "running", to: "budget-exhausted", cause: "system" }); + assert.equal(foldStateMachine({ graph: testGraph, records }).state, "budget-exhausted"); +}); + +test("an atomic sequence-256 terminal event folds to the declared terminal projection", () => { + const first = createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() }); + const running = createJournalRecord({ sequence: 2, prevDigest: first.digest, at: 1, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }); + const leased = createJournalRecord({ sequence: 3, prevDigest: running.digest, at: 2, type: "lease-acquired", payload: { generation: 1, token: "a".repeat(64) } }); + const terminal = createJournalRecord({ sequence: 256, prevDigest: leased.digest, at: 3, type: "terminal-node-committed", payload: { kind: "exhausted", summary: "minutes", from: "running", to: "budget-exhausted", nodeId: "exhausted", attempt: 1 } }); + const folded = foldRun([first, running, leased, terminal]); + assert.equal(folded.projection.sequence, 256); assert.equal(folded.projection.state, "budget-exhausted"); assert.equal(folded.execution.system.kind, "exhausted"); +}); + +test("capacity terminalization clears an owner and is closed for every recoverable lifecycle state", () => { + const first = createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() }); + const terminal = (prior, from) => createJournalRecord({ sequence: 256, prevDigest: prior.digest, at: 4, type: "terminal-node-committed", payload: { kind: "exhausted", summary: "journal", from, to: "budget-exhausted", nodeId: "exhausted", attempt: 1 } }); + const prepared = foldRun([first, terminal(first, "prepared")]); assert.equal(prepared.projection.leaseHeld, false); + const running = createJournalRecord({ sequence: 2, prevDigest: first.digest, at: 1, type: "state-changed", payload: { from: "prepared", to: "running", cause: "control" } }); + const noOwner = foldRun([first, running, terminal(running, "running")]); assert.equal(noOwner.projection.leaseHeld, false); + const owner = createJournalRecord({ sequence: 3, prevDigest: running.digest, at: 2, type: "lease-acquired", payload: { generation: 1, token: "a".repeat(64) } }); + const owned = foldRun([first, running, owner, terminal(owner, "running")]); assert.equal(owned.projection.leaseHeld, false); + const paused = createJournalRecord({ sequence: 4, prevDigest: owner.digest, at: 3, type: "state-changed", payload: { from: "running", to: "paused", cause: "control" } }); + const pausedTerminal = foldRun([first, running, owner, paused, terminal(paused, "paused")]); assert.equal(pausedTerminal.projection.leaseHeld, false); + const stopped = createJournalRecord({ sequence: 4, prevDigest: owner.digest, at: 3, type: "state-changed", payload: { from: "running", to: "stopped", cause: "control" } }); + const contradictory = createJournalRecord({ sequence: 5, prevDigest: stopped.digest, at: 4, type: "terminal-node-committed", payload: { kind: "error", summary: "wrong terminal kind", from: "stopped", to: "stopped", nodeId: "failed", attempt: 1 } }); + assert.throws(() => foldRun([first, running, owner, stopped, contradictory]), /invalid atomic terminal/u); +}); diff --git a/src/loops/view/render.mjs b/src/loops/view/render.mjs new file mode 100644 index 00000000..90b3ed4b --- /dev/null +++ b/src/loops/view/render.mjs @@ -0,0 +1,106 @@ +import { outcomesFor } from "../dsl/grammar.mjs"; +import { validateClosedIr } from "../dsl/ir-validate.mjs"; + +const SYSTEM = ["error", "timeout", "cancelled", "lost", "exhausted"]; +const MODES = new Set(["UNPINNED", "ITEM-PINNED", "RUN-FROZEN"]); +const REVISION = /^(?:ls1|lp1|er1)-sha256:[a-f0-9]{64}$/u; +const MAX_ROWS = 1024; +const MAX_BYTES = 256 * 1024; + +export class LoopViewError extends Error { + constructor(code, message) { super(`${code}: ${message}`); this.name = "LoopViewError"; this.code = code; } +} + +function fail(code, message) { throw new LoopViewError(code, message); } +function utf8(value) { return Buffer.from(value, "utf8"); } +function compare(left, right) { return Buffer.compare(utf8(left), utf8(right)); } +function scalar(value, label) { + if (typeof value !== "string" || value.length === 0 || /[\u0000-\u001f\u007f-\u009f\uD800-\uDFFF]/u.test(value) || value.includes("\n") || value.includes("\r")) fail("ELOOP_VIEW_VALUE", `${label} is not a safe single-line value`); + return value; +} +function revision(value, label, prefix) { + if (typeof value !== "string" || !REVISION.test(value) || !value.startsWith(`${prefix}-`)) fail("ELOOP_VIEW_IR_INVALID", `${label} is invalid`); + return value; +} +function recipe(value, label) { + if (!value || typeof value !== "object" || !value.ir || !value.revisions) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is missing`); + if (!validateClosedIr(value.ir)) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is not closed normalized Stage-1 IR`); + const r = value.revisions; + revision(r.source, `${label} source revision`, "ls1"); revision(r.package, `${label} package revision`, "lp1"); revision(r.executable, `${label} executable revision`, "er1"); + return value; +} +function currentRecipe(authority) { return authority.currentCompiled ?? authority.current ?? null; } +function provenance(recipeValue) { return recipeValue?.revisions ?? {}; } +function status(assigned, current, mode) { + if (mode === "UNPINNED") return "not-applicable"; + if (mode === "RUN-FROZEN") return "not-checked"; + if (!current) return "unavailable"; + return assigned === current ? "match" : "drift"; +} + +function orderedNodes(ir) { + return [...ir.nodes].sort((a, b) => a.id === ir.entry ? -1 : b.id === ir.entry ? 1 : compare(a.id, b.id)); +} + +function graph(ir) { + const nodes = orderedNodes(ir), byId = new Map(nodes.map((node) => [node.id, node])); + const declared = new Map(ir.edges.map((edge) => [`${edge.from}\0${edge.on}`, edge])); + const expanded = []; + for (const node of nodes) { + if (node.kind === "terminal") continue; + for (const on of outcomesFor(node)) { const edge = declared.get(`${node.id}\0${on}`); expanded.push({ from: node.id, on, to: edge.to, maxVisits: edge.maxVisits, className: "semantic" }); } + for (const on of SYSTEM) expanded.push({ from: node.id, on, to: ir.failurePolicy[on], maxVisits: null, className: "system" }); + } + if (expanded.length > MAX_ROWS) fail("ELOOP_VIEW_ADJACENCY_CAP", `expanded adjacency exceeds ${MAX_ROWS} rows`); + const adjacency = new Map(nodes.map((node) => [node.id, []])); + for (const edge of expanded) adjacency.get(edge.from).push(edge.to); + // Tarjan follows the same byte-stable node and edge order as the view. + const index = new Map(), low = new Map(), stack = [], onStack = new Set(), components = []; let next = 0; + function visit(id) { + index.set(id, next); low.set(id, next++); stack.push(id); onStack.add(id); + const targets = [...new Set(adjacency.get(id))].sort(compare); + for (const target of targets) { + if (!index.has(target)) { visit(target); low.set(id, Math.min(low.get(id), low.get(target))); } + else if (onStack.has(target)) low.set(id, Math.min(low.get(id), index.get(target))); + } + if (low.get(id) === index.get(id)) { const component = []; let item; do { item = stack.pop(); onStack.delete(item); component.push(item); } while (item !== id); components.push(component.sort(compare)); } + } + for (const node of nodes) if (!index.has(node.id)) visit(node.id); + components.sort((a, b) => compare(a[0], b[0])); + const scc = new Map(); components.forEach((component, i) => component.forEach((id) => scc.set(id, i + 1))); + return { nodes, expanded, scc, byId }; +} + +function authorityRecipe(authority) { + if (!authority || typeof authority !== "object") fail("ELOOP_VIEW_AUTHORITY", "authority is required"); + const mode = authority.authority; + if (!MODES.has(mode)) fail("ELOOP_VIEW_AUTHORITY", "unknown authority mode"); + const selected = mode === "UNPINNED" ? authority.compiled : mode === "ITEM-PINNED" ? authority.artifact?.frozen : authority.frozen; + return { mode, selected: recipe(selected, mode.toLowerCase()), current: mode === "ITEM-PINNED" ? currentRecipe(authority) : mode === "UNPINNED" ? authority.compiled : null }; +} + +export function renderResolvedLoopView(authority) { + const { mode, selected, current } = authorityRecipe(authority), ir = selected.ir; + const currentValid = current ? (() => { try { return recipe(current, "current"); } catch { return null; } })() : null; + const selectedRevisions = provenance(selected), currentRevisions = provenance(currentValid); + const selector = scalar(authority.selector, "selector"), loop = scalar(authority.loopRef ?? `loop:builtin:${ir.id}`, "loop selector"); + const sourceAssigned = mode === "UNPINNED" ? "-" : scalar(selectedRevisions.source, "assigned source revision"); + const packageAssigned = mode === "UNPINNED" ? "-" : scalar(selectedRevisions.package, "assigned package revision"); + const sourceCurrent = mode === "RUN-FROZEN" ? "not-checked" : currentValid ? scalar(currentRevisions.source, "current source revision") : mode === "UNPINNED" ? scalar(selectedRevisions.source, "current source revision") : "unavailable"; + const packageCurrent = mode === "RUN-FROZEN" ? "not-checked" : currentValid ? scalar(currentRevisions.package, "current package revision") : mode === "UNPINNED" ? scalar(selectedRevisions.package, "current package revision") : "unavailable"; + const executionAssigned = mode === "UNPINNED" ? "-" : scalar(selectedRevisions.executable, "assigned execution revision"); + const executionCurrent = mode === "RUN-FROZEN" ? "not-checked" : currentValid ? scalar(currentRevisions.executable, "current execution revision") : mode === "UNPINNED" ? scalar(selectedRevisions.executable, "current execution revision") : "unavailable"; + const g = graph(ir); + const lines = ["BURNLIST LOOP VIEW @1", `MODE: ${mode}`, `SELECTOR: ${selector}`, `LOOP: ${loop}`, `DECLARED-VERSION: ${scalar(ir.declaredVersion, "declared version")}`, `COMPILER: ${scalar(ir.compiler, "compiler contract")}`, `EXECUTION: assigned=${executionAssigned} current=${executionCurrent} status=${status(executionAssigned, executionCurrent === "unavailable" ? null : executionCurrent, mode)}`, `SOURCE: assigned=${sourceAssigned} current=${sourceCurrent} status=${status(sourceAssigned, sourceCurrent === "unavailable" ? null : sourceCurrent, mode)}`, `PACKAGE: assigned=${packageAssigned} current=${packageCurrent} status=${status(packageAssigned, packageCurrent === "unavailable" ? null : packageCurrent, mode)}`, `PIN: ${mode === "UNPINNED" ? "unpinned" : mode === "ITEM-PINNED" ? "item-pinned" : "run-frozen"}`, "DRAWING (DECORATIVE):"]; + for (const edge of g.expanded.filter((item) => item.className === "semantic")) + lines.push(` ${edge.from === ir.entry ? "* " : " "}${edge.from} --${edge.on}--> ${edge.to}`); + lines.push("ADJACENCY (AUTHORITATIVE):"); + for (const node of g.nodes) { + lines.push(`${node.id} [kind=${node.kind} scc=${g.scc.get(node.id)}]`); + for (const edge of g.expanded.filter((item) => item.from === node.id)) lines.push(` ${edge.on} -> ${edge.to} [class=${edge.className} max-visits=${edge.maxVisits ?? "-"}]`); + } + lines.push("COMPLETION:", " converged -> cli-completion -> completed|completion-needs-human", "END"); + const output = `${lines.join("\n")}\n`; + if (Buffer.byteLength(output, "utf8") > MAX_BYTES) fail("ELOOP_VIEW_OUTPUT_CAP", `rendered output exceeds ${MAX_BYTES} bytes`); + return output; +} diff --git a/src/loops/view/render.test.mjs b/src/loops/view/render.test.mjs new file mode 100644 index 00000000..4dfd2cd9 --- /dev/null +++ b/src/loops/view/render.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { LoopViewError, renderResolvedLoopView } from "./render.mjs"; + +const ir = JSON.parse(readFileSync(new URL("../dsl/__fixtures__/review.ir.json", import.meta.url))); +const revisions = JSON.parse(readFileSync(new URL("../dsl/__fixtures__/review.revisions.json", import.meta.url))); +const compiled = { ir, revisions }; +const base = { authority: "UNPINNED", selector: "loop:builtin:review", compiled }; + +function outputDigest(value) { return createHash("sha256").update(value).digest("hex"); } + +test("renders the closed review graph byte-deterministically", () => { + const output = renderResolvedLoopView(base); + assert.equal(outputDigest(output), "cf8421f1e0c0017178e1563e75a7cd42455fdc0106bff862410916d9cf3cb0b9"); + assert.equal(output, renderResolvedLoopView({ ...base, terminalWidth: 1 })); + assert.match(output, /^BURNLIST LOOP VIEW @1\nMODE: UNPINNED/m); + assert.match(output, /DRAWING \(DECORATIVE\):\n \* implement --complete--> verify\n(?: .+\n)+ADJACENCY \(AUTHORITATIVE\):/); + for (const outcome of ["complete", "pass", "fail", "approve", "reject", "escalate", "error", "timeout", "cancelled", "lost", "exhausted"]) assert.match(output, new RegExp(`^ ${outcome} -> `, "m")); + assert.match(output, /^ reject -> implement \[class=semantic max-visits=3\]$/m); + assert.match(output, /^implement \[kind=agent scc=5\]$/m); + assert.match(output, /COMPLETION:\n converged -> cli-completion -> completed\|completion-needs-human\nEND\n$/); + assert.doesNotMatch(output, /\x1b|\r/); +}); + +test("renders each authority mode and item drift/unavailability", () => { + const item = renderResolvedLoopView({ authority: "ITEM-PINNED", selector: "item:260722-001#review", artifact: { frozen: compiled } }); + assert.match(item, /MODE: ITEM-PINNED/); + assert.match(item, /SOURCE: assigned=ls1-sha256:[a-f0-9]{64} current=unavailable status=unavailable/); + const drift = renderResolvedLoopView({ authority: "ITEM-PINNED", selector: "item:260722-001#review", artifact: { frozen: compiled }, currentCompiled: { ir, revisions: { ...revisions, source: `ls1-sha256:${"a".repeat(64)}` } } }); + assert.match(drift, /SOURCE: assigned=ls1-sha256:[a-f0-9]{64} current=ls1-sha256:a{64} status=drift/); + const frozen = renderResolvedLoopView({ authority: "RUN-FROZEN", selector: "run:0123456789abcdefghjkmnpqrs", frozen: compiled }); + assert.match(frozen, /MODE: RUN-FROZEN/); + assert.match(frozen, /SOURCE: assigned=ls1-sha256:[a-f0-9]{64} current=not-checked status=not-checked/); +}); + +test("keeps item graph pinned while reporting complete current provenance", () => { + const current = { ir, revisions: { source: `ls1-sha256:${"a".repeat(64)}`, package: `lp1-sha256:${"b".repeat(64)}`, executable: `er1-sha256:${"c".repeat(64)}` } }; + const output = renderResolvedLoopView({ authority: "ITEM-PINNED", selector: "item:260722-001#review", loopRef: "loop:builtin:review", artifact: { frozen: compiled }, currentCompiled: current }); + assert.match(output, new RegExp(`EXECUTION: assigned=${revisions.executable} current=er1-sha256:c{64} status=drift`)); + assert.match(output, /SOURCE: assigned=.* current=ls1-sha256:a{64} status=drift/); + assert.match(output, /PACKAGE: assigned=.* current=lp1-sha256:b{64} status=drift/); + assert.match(output, /^implement \[kind=agent scc=5\]$/m); +}); + +test("rejects malformed IR, control values, and oversized adjacency before returning", () => { + assert.throws(() => renderResolvedLoopView({ ...base, compiled: { ir: { ...ir, compiler: "unsupported" }, revisions } }), (error) => error instanceof LoopViewError && error.code === "ELOOP_VIEW_IR_INVALID"); + assert.throws(() => renderResolvedLoopView({ ...base, selector: "loop:builtin:review\nansi" }), (error) => error.code === "ELOOP_VIEW_VALUE"); + const tooMany = { ...ir, nodes: ir.nodes.map((node) => ({ ...node, id: `${node.id}-${"x".repeat(65500)}` })) }; + assert.throws(() => renderResolvedLoopView({ ...base, compiled: { ir: tooMany, revisions } }), (error) => error.code === "ELOOP_VIEW_IR_INVALID"); + assert.throws(() => renderResolvedLoopView({ ...base, selector: `loop:builtin:${"x".repeat(300000)}` }), (error) => error.code === "ELOOP_VIEW_OUTPUT_CAP"); +}); From 56c147c5d675b7993f0f798c5c3b289d2186f7b4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:03 +0200 Subject: [PATCH 02/23] feat: connect loop configuration and lifecycle CLI --- .gitignore | 1 + src/cli/commands-help.test.mjs | 164 +++++++++++++++++++++++- src/cli/lifecycle-moves.mjs | 35 +++++- src/cli/loop-cli.mjs | 101 +++++++++++++++ src/cli/loop-cli.test.mjs | 203 ++++++++++++++++++++++++++++++ src/cli/loop-config-cli.mjs | 128 +++++++++++++++++++ src/cli/loop-runtime-cli.test.mjs | 121 ++++++++++++++++++ src/server/dir-lock.mjs | 40 ++++-- src/server/dir-lock.test.mjs | 39 +++++- src/server/plan-model.mjs | 8 ++ 10 files changed, 821 insertions(+), 19 deletions(-) create mode 100644 src/cli/loop-cli.mjs create mode 100644 src/cli/loop-cli.test.mjs create mode 100644 src/cli/loop-config-cli.mjs create mode 100644 src/cli/loop-runtime-cli.test.mjs diff --git a/.gitignore b/.gitignore index 494128ad..4035ca1b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dashboard/dist/ node_modules/ .oven-test-*/ .playwright-cli/ +.worktrees/ output/ build/ *.zip diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index cffe5bc3..2285c6d7 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); const cli = join(root, "bin", "burnlist.mjs"); @@ -43,6 +45,17 @@ test("top-level and Oven help expose the validated use and set flow", () => { const top = run(context.directory, ["--help"]); assert.equal(top.status, 0, top.stderr); assert.match(top.stdout, /burnlist oven <[^\n]*use[^\n]*set[^\n]*>/u); + assert.match(top.stdout, /burnlist loop view \[--repo \]/u); + assert.match(top.stdout, /burnlist loop create \[--repo \]/u); + assert.match(top.stdout, /burnlist loop list \[--repo \]/u); + assert.match(top.stdout, /burnlist loop run\|resume \[--repo \]/u); + assert.match(top.stdout, /burnlist loop status\|inspect \[--repo \]/u); + assert.match(top.stdout, /burnlist loop pause\|stop \[--repo \] \(idle Run only\)/u); + assert.match(top.stdout, /burnlist loop reconcile --recovery-proof \[--repo \]/u); + assert.match(top.stdout, /burnlist loop complete \[--repo \]/u); + assert.match(top.stdout, /burnlist loop capability \.\.\./u); + assert.match(top.stdout, /burnlist agent \.\.\./u); + assert.match(top.stdout, /burnlist route set --profile \[--repo \]/u); const oven = run(context.directory, ["oven", "help"]); assert.equal(oven.status, 0, oven.stderr); @@ -54,6 +67,39 @@ test("top-level and Oven help expose the validated use and set flow", () => { } finally { context.cleanup(); } }); +test("Loop local configuration help exposes only explicit setup commands", () => { + const context = fixture({ git: false }); + try { + for (const [args, usage] of [ + [["agent", "--help"], /burnlist agent profile add /u], + [["agent", "--help"], /burnlist agent doctor /u], + [["route", "--help"], /burnlist route set /u], + [["loop", "--help"], /burnlist loop capability trust --revision cp1-sha256: --grants /u], + ]) { + const result = run(context.directory, args); + assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, usage); assert.equal(result.stderr, ""); + } + for (const args of [["agent", "controller", "add", "host"], ["agent", "preflight", "maker"]]) { + const result = run(context.directory, args); + assert.equal(result.status, 2, args.join(" ")); + assert.match(result.stderr, /Usage: burnlist agent profile add/u); + } + } finally { context.cleanup(); } +}); + +test("nested Loop help snapshots every Stage 1 control", () => { + const context = fixture({ git: false }); + try { + const result = run(context.directory, ["loop", "--help"]); + assert.equal(result.status, 0, result.stderr); + for (const command of ["create", "list", "run|pause|resume|stop|complete", "status|inspect", "reconcile"]) { + assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u")); + } + assert.match(result.stdout, /pause\|resume\|stop\|complete /u); + assert.equal(result.stderr, ""); + } finally { context.cleanup(); } +}); + test("empty skill and hook uninstalls report that there is nothing to remove", () => { const context = fixture(); try { @@ -97,3 +143,119 @@ test("hooks status and uninstall name their own Git requirement", () => { } } finally { context.cleanup(); } }); + +test("Review Loop documentation command matrix runs against the production fixture", (t) => { + const directory = mkdtempSync(join(tmpdir(), "burnlist-loop-docs-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo, binary } = createProductionRunAuthority(join(directory, "repo")); + const loop = (...args) => run(repo, ["loop", ...args, "--repo", repo]); + const command = (...args) => run(repo, [...args, "--repo", repo]); + const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); + writeFileSync(planPath, readFileSync(planPath, "utf8").replace("## Completed", [ + "- [ ] L30 | Direct workflow fixture", + "- [ ] L31 | Pause and resume fixture", + "- [ ] L32 | Stop fixture", + "- [ ] L33 | Reconcile fixture", + "", + "## Completed", + ].join("\n"))); + const profile = (slug, authority) => command("agent", "profile", "add", slug, + "--adapter", "builtin:codex-cli", "--binary", binary, "--model", "gpt-5.3-codex-spark", + "--effort", "medium", "--authority", authority); + for (const result of [profile("maker", "write"), profile("reviewer", "read"), + command("route", "set", "implementation.standard", "--profile", "maker"), + command("route", "set", "review.strong", "--profile", "reviewer"), + command("agent", "doctor", "maker"), command("agent", "doctor", "reviewer")]) { + assert.equal(result.status, 0, result.stderr); + } + const inspected = loop("capability", "inspect", "repo-verify"); + assert.equal(inspected.status, 0, inspected.stderr); + const revision = JSON.parse(inspected.stdout).revision; + const trusted = loop("capability", "trust", "repo-verify", "--revision", revision, "--grants", join(repo, "grants.json")); + assert.equal(trusted.status, 0, trusted.stderr); + for (const args of [["setup", "status"], ["list"], ["view", fixtureItemRef]]) { + const result = loop(...args); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); + } + + const directRef = "item:260722-001#L30"; + const assignedDirect = loop("assign", directRef, "loop:builtin:review"); + assert.equal(assignedDirect.status, 0, assignedDirect.stderr); + const blockedBurn = command("burn", "260722-001", "L30", "--check"); + assert.equal(blockedBurn.status, 1); + assert.match(blockedBurn.stderr, /direct burn is blocked by Loop metadata/u); + const unassigned = loop("unassign", directRef); + assert.equal(unassigned.status, 0, unassigned.stderr); + const directBurn = command("burn", "260722-001", "L30", "--check"); + assert.equal(directBurn.status, 0, directBurn.stderr); + + const created = loop("create", fixtureItemRef); + assert.equal(created.status, 0, created.stderr); + const runId = JSON.parse(created.stdout).runId; + for (const args of [["status", runId], ["inspect", runId]]) { + const result = loop(...args); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); + } + const counter = join(directory, "counter"); + writeFileSync(counter, "0"); + const executed = spawnSync(process.execPath, [cli, "loop", "run", runId, "--repo", repo], { + cwd: repo, + encoding: "utf8", + env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" }, + }); + assert.equal(executed.status, 0, executed.stderr); + assert.equal(JSON.parse(executed.stdout).state, "converged"); + for (const args of [["complete", runId], ["complete", runId]]) { + const result = loop(...args); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); + } + + const pauseRef = "item:260722-001#L31"; + assert.equal(loop("assign", pauseRef, "loop:builtin:review").status, 0); + const pausedRun = JSON.parse(loop("create", pauseRef).stdout).runId; + const paused = loop("pause", pausedRun); + assert.equal(paused.status, 0, paused.stderr); + assert.equal(JSON.parse(paused.stdout).state, "paused"); + writeFileSync(counter, "0"); + const resumed = spawnSync(process.execPath, [cli, "loop", "resume", pausedRun, "--repo", repo], { + cwd: repo, encoding: "utf8", + env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" }, + }); + assert.equal(resumed.status, 0, resumed.stderr); + assert.equal(JSON.parse(resumed.stdout).state, "converged"); + + const stoppedRef = "item:260722-001#L32"; + assert.equal(loop("assign", stoppedRef, "loop:builtin:review").status, 0); + const stoppedRun = JSON.parse(loop("create", stoppedRef).stdout).runId; + const stopped = loop("stop", stoppedRun); + assert.equal(stopped.status, 0, stopped.stderr); + assert.equal(JSON.parse(stopped.stdout).state, "stopped"); + + const reconciledRef = "item:260722-001#L33"; + assert.equal(loop("assign", reconciledRef, "loop:builtin:review").status, 0); + const reconciledRun = JSON.parse(loop("create", reconciledRef).stdout).runId; + const store = runStore(repo), acquired = store.acquireLease(reconciledRun); + store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 }); + store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); + const reconciled = loop("reconcile", reconciledRun, "--recovery-proof", acquired.recoveryProof); + assert.equal(reconciled.status, 0, reconciled.stderr); + assert.equal(JSON.parse(reconciled.stdout).state, "needs-human"); +}); + +test("Review Loop documentation preserves Stage 1 boundaries", () => { + const files = [ + join(root, "README.md"), + join(root, "website", "src", "content", "docs", "loops.mdx"), + join(root, "skills", "burnlist", "SKILL.md"), + ].map((path) => readFileSync(path, "utf8")).join("\n"); + assert.match(files, /filesystem write denial.*supervised/us); + assert.match(files, /fresh reviewer process.*enforced/us); + assert.match(files, /[Pp]arallelism.*unsupported/us); + assert.match(files, /Docker isolation.*unsupported/us); + assert.match(files, /custom adapters.*unsupported/us); + assert.match(files, /forecasting.*unsupported/us); + assert.match(files, /skill.*hooks.*independent/us); + assert.match(files, /loop-capability-example\.json/us); + assert.match(files, /gpt-5\.6-terra/us); + assert.match(files, /idle.*foreground owner/us); +}); diff --git a/src/cli/lifecycle-moves.mjs b/src/cli/lifecycle-moves.mjs index a3872bda..13620e82 100644 --- a/src/cli/lifecycle-moves.mjs +++ b/src/cli/lifecycle-moves.mjs @@ -6,6 +6,7 @@ import { mkdirSync, openSync, readFileSync, + readdirSync, renameSync, rmSync, rmdirSync, @@ -25,6 +26,7 @@ import { ovenLifecycleChangedInput, publishCanonicalMutation, } from "../events/oven-canonical-mutations.mjs"; +import { assertDirectBurnAllowed } from "../loops/assignment/assignment.mjs"; export { withLock } from "../server/fs-safe.mjs"; @@ -176,6 +178,7 @@ export function startLifecycle(repoRoot, id, eventOptions) { export function closeLifecycle(repoRoot, id, eventOptions) { assertValidBurnlistId(id); + assertNoPendingLoopCompletion(repoRoot, id); const inprogressDir = join(repoRoot, "notes", "burnlists", "inprogress", id); const completedDir = join(repoRoot, "notes", "burnlists", "completed", id); if (!safeStat(inprogressDir)?.isDirectory() && safeStat(completedDir)?.isDirectory()) { @@ -201,6 +204,29 @@ export function closeLifecycle(repoRoot, id, eventOptions) { }); } +// Completion publishes an intent before it rewrites the shrinking plan. Do +// not move that plan out from under a recoverable intent: the retry owns the +// receipt and is the only writer that may clear it. +function assertNoPendingLoopCompletion(repoRoot, id) { + const runs = join(repoRoot, ".local", "burnlist", "loop", "m2", "runs"); + if (!safeStat(runs)?.isDirectory()) return; + for (const entry of readdirSync(runs, { withFileTypes: true })) { + if (!entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name)) continue; + const intent = join(runs, entry.name, "completion-intent.json"), stat = safeStat(intent); + if (!stat?.isFile() || stat.size < 2 || stat.size > 8192) continue; + try { + const value = JSON.parse(readFileSync(intent, "utf8")); + if (typeof value?.itemRef === "string" && value.itemRef.startsWith(`item:${id}#`)) + throw new Error(`not ready to close: pending Loop completion for ${value.itemRef}`); + } catch (error) { + if (error?.message?.startsWith("not ready to close:")) throw error; + // A corrupt unrelated scratch record cannot be interpreted as a safe + // absence. It is conservative only for the lifecycle being closed. + throw new Error("not ready to close: pending Loop completion is unreadable"); + } + } +} + function assertCloseGate(plan) { if (plan.items.length || !plan.completed.length) { throw new Error("not ready to close: active checklist must be empty with completed entries"); @@ -249,7 +275,7 @@ function appendCompleted(lines, entry) { lines.splice(end < 0 ? lines.length : end, 0, entry); } -function checklistCompletion(id, item, completedAt, plan) { +export function checklistCompletion(id, item, completedAt, plan) { const total = plan.items.length + plan.completed.length; return { ovenId: "checklist", @@ -277,7 +303,7 @@ function printBurnCheck(plan, issues) { console.log(`Burnlist check passed: ${plan.items.length} active, ${plan.completed.length} completed.`); } -export function burnItem(repoRoot, id, itemId, check = false) { +export function burnItem(repoRoot, id, itemId, check = false, { hazardAuthority, completedAt: requestedCompletedAt } = {}) { assertValidBurnlistId(id); const found = findBurnlistDir(repoRoot, id); if (found.lifecycle.folder !== "inprogress") { @@ -296,8 +322,11 @@ export function burnItem(repoRoot, id, itemId, check = false) { if (check) printBurnCheck(plan, currentIssues); return checklistCompletion(id, completed, completed.completedAt, plan); } + // Direct lifecycle burns intentionally remain unchanged for unassigned + // items, but must never bypass any Loop-shaped metadata. + assertDirectBurnAllowed({ repoRoot, itemRef: `item:${id}#${itemId}`, markdown: plan.markdown, hazardAuthority }); const lines = removeActiveItem(plan.markdown, itemId); - const completedAt = localIsoTimestamp(); + const completedAt = requestedCompletedAt ?? localIsoTimestamp(); appendCompleted(lines, `- ${item.id} | ${completedAt} | ${item.title}`); const nextMarkdown = `${lines.join("\n").replace(/\s*$/u, "")}\n`; const temporary = join(dirname(planPath), `.${basename(planPath)}.${randomBytes(8).toString("hex")}.tmp`); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs new file mode 100644 index 00000000..2df8dfab --- /dev/null +++ b/src/cli/loop-cli.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { assignLoopItem, prepareItemMutation, unassignLoopItem } from "../loops/assignment/assignment.mjs"; +import { resolveLoopAuthority } from "../loops/assignment/resolver.mjs"; +import { loopConfigUsage, runLoopConfigCli } from "./loop-config-cli.mjs"; +import { resolveUmbrella } from "./umbrella.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; +import { createLoopController } from "../loops/run/controller.mjs"; +import { createProductionRun, createStoredProductionRunRunner } from "../loops/run/binder.mjs"; +import { completeLoopRun } from "../loops/completion/completion.mjs"; + +function usageText() { return loopConfigUsage(); } +function options(tokens) { + const positionals = []; let repo = null, recoveryProof = null; + for (let index = 0; index < tokens.length; index += 1) { + if (tokens[index] === "--repo") { + if (repo !== null) throw new Error("--repo must be specified at most once."); + repo = tokens[++index]; + if (!repo || repo.startsWith("--")) throw new Error("--repo requires a path."); + } + else if (tokens[index] === "--recovery-proof") { + if (recoveryProof !== null) throw new Error("--recovery-proof must be specified at most once."); + recoveryProof = tokens[++index]; if (!/^[a-f0-9]{64}$/u.test(recoveryProof ?? "")) throw new Error("--recovery-proof requires a 64-character lowercase hex value."); + } + else if (tokens[index].startsWith("--")) throw new Error(`Unknown option: ${tokens[index]}`); + else positionals.push(tokens[index]); + } + return { positionals, recoveryProof, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; +} + +export async function renderLoopView({ selector, repoRoot, runReader }) { + const authority = await resolveLoopAuthority({ repoRoot, selector, runReader }); + const { renderResolvedLoopView } = await import("../loops/view/render.mjs"); + return { authority, output: renderResolvedLoopView(authority) }; +} + +export async function runLoopCli(tokens, { runReader, runnerFor, stdout = process.stdout, processObject = process } = {}) { + if (tokens[0] === "--help" || tokens[0] === "-h") { stdout.write(`${usageText()}\n`); return null; } + if (["capability", "setup"].includes(tokens[0])) { + const value = await runLoopConfigCli(tokens); stdout.write(value.output); return value; + } + const [verb, ...rest] = tokens; const opts = options(rest); + if (verb === "create") { + if (opts.positionals.length !== 1 || opts.recoveryProof) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + const store = runStore(opts.repo), result = await createProductionRun({ repoRoot: opts.repo, store, itemRef: opts.positionals[0] }); + stdout.write(`${JSON.stringify({ schema: "burnlist-loop-status@1", ...result.projection })}\n`); return result; + } + if (verb === "complete") { + if (opts.positionals.length !== 1 || opts.recoveryProof) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + const result = completeLoopRun({ repoRoot: opts.repo, runId: opts.positionals[0] }); + stdout.write(`${JSON.stringify({ schema: "burnlist-loop-completion@1", ...result })}\n`); return result; + } + if (["list", "status", "inspect", "run", "pause", "resume", "stop", "reconcile"].includes(verb)) { + const allowed = verb === "list" ? 0 : 1; + if (opts.positionals.length !== allowed) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + const store = runStore(opts.repo); + if (opts.recoveryProof && verb !== "reconcile") { const error = new Error(usageText()); error.exitCode = 2; throw error; } + const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredProductionRunRunner({ repoRoot: opts.repo, store, runId })); + const runners = new Map(), runtimeRunnerFor = (runId) => { + if (!runners.has(runId)) runners.set(runId, suppliedRunnerFor(runId)); + return runners.get(runId); + }; + const controller = createLoopController({ store, runnerFor: runtimeRunnerFor }); + const result = verb === "list" ? controller.list() + : verb === "status" ? controller.status(opts.positionals[0]) + : verb === "inspect" ? controller.inspect(opts.positionals[0]) + : verb === "pause" ? controller.pause(opts.positionals[0]) + : verb === "stop" ? controller.stop(opts.positionals[0]) + : verb === "reconcile" ? controller.reconcile(opts.positionals[0], opts.recoveryProof ? { generation: store.read(opts.positionals[0]).execution.generation, recoveryProof: opts.recoveryProof } : null) + : verb === "resume" || verb === "run" ? await (() => { + const runner = runtimeRunnerFor(opts.positionals[0]); let signalled = false; + const onInterrupt = () => { if (!signalled) { signalled = true; runner.requestPause?.(); } else runner.requestStop?.(); }; + processObject.on?.("SIGINT", onInterrupt); + return controller.run(opts.positionals[0]).finally(() => processObject.removeListener?.("SIGINT", onInterrupt)); + })() : null; + stdout.write(controller.render(result)); return result; + } + if (verb === "assign" && opts.positionals.length === 2) { + const prepared = prepareItemMutation({ repoRoot: opts.repo, itemRef: opts.positionals[0] }); + const result = await assignLoopItem({ repoRoot: opts.repo, itemRef: opts.positionals[0], loopRef: opts.positionals[1], prepared }); + stdout.write(`${result.assignmentId}\n${result.selector}\n${result.executionRevision}\n`); return result; + } + if (verb === "unassign" && opts.positionals.length === 1) { + const prepared = prepareItemMutation({ repoRoot: opts.repo, itemRef: opts.positionals[0] }); + const result = unassignLoopItem({ repoRoot: opts.repo, itemRef: opts.positionals[0], prepared }); + stdout.write(`${result.assignmentId}\n`); return result; + } + if (verb === "view" && opts.positionals.length === 1) { + const result = await renderLoopView({ selector: opts.positionals[0], repoRoot: opts.repo, runReader }); + stdout.write(result.output); return result.authority; + } + const error = new Error(usageText()); error.exitCode = 2; throw error; +} + +export async function runLoopCliEntry(tokens = process.argv.slice(3)) { + try { const result = await runLoopCli(tokens); process.exitCode = result?.exitCode ?? 0; return result?.result ?? result; } + catch (error) { + process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); + process.exitCode = error?.exitCode ?? 1; return null; + } +} diff --git a/src/cli/loop-cli.test.mjs b/src/cli/loop-cli.test.mjs new file mode 100644 index 00000000..d81bf1c2 --- /dev/null +++ b/src/cli/loop-cli.test.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { runLoopCli } from "./loop-cli.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cli = join(root, "bin", "burnlist.mjs"); +const itemRef = "item:260722-001#BUG-07"; +const runRef = "run:01arz3ndektsv4rrffq69g5fav"; + +function fixture({ git = true } = {}) { + const directory = mkdtempSync(join(tmpdir(), "burnlist-loop-cli-")); + const repo = join(directory, "repo"); + mkdirSync(repo, { recursive: true }); + if (git) execFileSync("git", ["init", "--quiet", repo]); + const planDir = join(repo, "notes", "burnlists", "inprogress", "260722-001"); + mkdirSync(planDir, { recursive: true }); + writeFileSync(join(planDir, "burnlist.md"), [ + "# Test", + "", + "## Active Checklist", + "- [ ] BUG-07 | Keep this line", + "", + "## Completed", + "", + ].join("\n")); + return { directory, repo, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; +} + +function runCommand(repo, args) { + return spawnSync(process.execPath, [cli, ...args], { cwd: repo, encoding: "utf8" }); +} +function assign(context) { + const result = runCommand(context.repo, ["loop", "assign", itemRef, "loop:builtin:review", "--repo", context.repo]); + assert.equal(result.status, 0, result.stderr); + const [assignmentId, selector, executable] = result.stdout.trimEnd().split("\n"); + return { assignmentId, selector, executable, path: join(context.repo, ".local", "burnlist", "loop", "v2", "assignments", assignmentId.slice(11)) }; +} +function pinError(executable) { + return `burnlist: ELOOP_PIN_BYTES_UNAVAILABLE: pinned=${executable} current=${executable}; restore the assignment artifact or safely unassign and reassign the item\n`; +} + +test("loop view renders unpinned review with canonical selector", () => { + const context = fixture(); + try { + const result = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo]); + assert.equal(result.status, 0, `${result.stderr}`); + assert.match(result.stdout, /loop:builtin:review/); + assert.equal(result.stderr, ""); + } finally { context.cleanup(); } +}); + +test("loop view renders item-pinned authority from fixture assignment", () => { + const context = fixture(); + try { + assign(context); + const result = runCommand(context.repo, ["loop", "view", itemRef, "--repo", context.repo]); + assert.equal(result.status, 0, `${result.stderr}`); + assert.match(result.stdout, /loop:builtin:review/); + assert.equal(result.stderr, ""); + } finally { context.cleanup(); } +}); + +test("loop view rejects invalid selectors and keeps stdout empty on failure", () => { + const context = fixture(); + try { + const invalid = runCommand(context.repo, ["loop", "view", "nonsense", "--repo", context.repo]); + assert.equal(invalid.status, 1); + assert.equal(invalid.stdout, ""); + assert.equal(invalid.stderr, "burnlist: E_LOOP_SELECTOR_INVALID: Invalid Loop selector: nonsense\n"); + } finally { context.cleanup(); } +}); + +test("item-pinned view reports stable recovery for missing, corrupt, and drifted artifacts", () => { + for (const mutation of ["missing", "corrupt", "symlink", "binding-drift"]) { + const context = fixture(); + try { + const assigned = assign(context); + if (mutation === "missing") rmSync(join(assigned.path, "recipe.frozen")); + if (mutation === "corrupt") writeFileSync(join(assigned.path, "recipe.frozen"), "not a frozen recipe\n"); + if (mutation === "symlink") { + rmSync(join(assigned.path, "recipe.frozen")); + symlinkSync(join(context.repo, "notes"), join(assigned.path, "recipe.frozen")); + } + if (mutation === "binding-drift") { + const path = join(assigned.path, "manifest.json"); + const manifest = JSON.parse(readFileSync(path, "utf8")); + manifest.itemRef = "item:260722-001#OTHER"; + writeFileSync(path, `${JSON.stringify(manifest)}\n`, { mode: 0o600 }); + } + const result = runCommand(context.repo, ["loop", "view", itemRef, "--repo", context.repo]); + assert.equal(result.status, 1, mutation); + assert.equal(result.stdout, "", mutation); + assert.equal(result.stderr, pinError(assigned.executable), mutation); + } finally { context.cleanup(); } + } +}); + +test("loop view rejects duplicate, missing, and unknown --repo options", () => { + const context = fixture(); + try { + const duplicate = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo, "--repo", context.repo]); + assert.equal(duplicate.status, 1); + assert.equal(duplicate.stdout, ""); + assert.match(duplicate.stderr, /--repo must be specified at most once/u); + + const missing = runCommand(context.repo, ["loop", "view", "review", "--repo"]); + assert.equal(missing.status, 1); + assert.equal(missing.stdout, ""); + assert.match(missing.stderr, /--repo requires a path/u); + + const unknown = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo, "--mystery"]); + assert.equal(unknown.status, 1); + assert.equal(unknown.stdout, ""); + assert.match(unknown.stderr, /Unknown option: --mystery/u); + } finally { context.cleanup(); } +}); + +test("loop view emits empty stdout for unavailable Run store and unavailable Loop package", () => { + const context = fixture(); + try { + const runUnavailable = runCommand(context.repo, ["loop", "view", runRef, "--repo", context.repo]); + assert.equal(runUnavailable.status, 1); + assert.equal(runUnavailable.stdout, ""); + assert.equal(runUnavailable.stderr, "burnlist: E_RUN_UNAVAILABLE: Run-frozen authority is unavailable before the Run store is installed\n"); + + const loopMissing = runCommand(context.repo, ["loop", "view", "loop:builtin:does-not-exist", "--repo", context.repo]); + assert.equal(loopMissing.status, 1); + assert.equal(loopMissing.stdout, ""); + assert.match(loopMissing.stderr, /not found/u); + } finally { context.cleanup(); } +}); + +test("injected verified Run reader renders identical bytes for TTY and pipe and ignores record loopRef", async () => { + const context = fixture(); + try { + const assigned = assign(context); + const frozenRecipe = readFileSync(join(assigned.path, "recipe.frozen")); + const runReader = async (runId) => ({ runId, loopRef: "loop:builtin:attacker\nINJECTED", frozenRecipe }); + const capture = (isTTY) => { + let bytes = ""; return { stream: { isTTY, write(value) { bytes += value; } }, read: () => bytes }; + }; + const pipe = capture(false), tty = capture(true); + await runLoopCli(["view", runRef, "--repo", context.repo], { runReader, stdout: pipe.stream }); + await runLoopCli(["view", runRef, "--repo", context.repo], { runReader, stdout: tty.stream }); + assert.equal(tty.read(), pipe.read()); + assert.match(pipe.read(), /^MODE: RUN-FROZEN$/m); + assert.match(pipe.read(), /^LOOP: loop:builtin:review$/m); + assert.match(pipe.read(), /^EXECUTION: assigned=er1-sha256:[a-f0-9]{64} current=not-checked status=not-checked$/m); + assert.doesNotMatch(pipe.read(), /attacker|INJECTED|\x1b|\r/u); + } finally { context.cleanup(); } +}); + +test("real executable emits the same view bytes through a pipe and a PTY", { skip: process.platform !== "darwin" }, () => { + const context = fixture(); + try { + const args = ["loop", "view", "review", "--repo", context.repo]; + const pipe = runCommand(context.repo, args); + assert.equal(pipe.status, 0, pipe.stderr); + const expect = [ + "set timeout 20", + 'spawn -noecho sh -c {stty -onlcr; exec "$BL_NODE" "$BL_CLI" loop view review --repo "$BL_REPO"}', + "expect eof", + "set result [wait]", + "exit [lindex $result 3]", + ].join("; "); + const pty = spawnSync("expect", ["-c", expect], { + cwd: context.repo, + encoding: "utf8", + env: { ...process.env, BL_NODE: process.execPath, BL_CLI: cli, BL_REPO: context.repo }, + }); + assert.equal(pty.status, 0, pty.stderr); + assert.equal(pty.stdout, pipe.stdout); + } finally { context.cleanup(); } +}); + +test("loop --help lists view and assign/unassign syntax", () => { + const context = fixture({ git: false }); + try { + const help = runCommand(context.repo, ["loop", "--help"]); + assert.equal(help.status, 0, help.stderr); + assert.equal(help.stderr, ""); + assert.match(help.stdout, /Usage: burnlist loop assign \[--repo \] \| burnlist loop unassign \[--repo \] \| burnlist loop view \[--repo \]/u); + } finally { context.cleanup(); } +}); + +test("existing loop assign/unassign smoke stays stable", () => { + const context = fixture(); + try { + const assign = runCommand(context.repo, ["loop", "assign", itemRef, "loop:builtin:review", "--repo", context.repo]); + assert.equal(assign.status, 0, `${assign.stderr}`); + assert.match(assign.stdout, /^as\d+-sha256:/u); + + const unassign = runCommand(context.repo, ["loop", "unassign", itemRef, "--repo", context.repo]); + assert.equal(unassign.status, 0, `${unassign.stderr}`); + assert.match(unassign.stdout, /^as\d+-sha256:/u); + } finally { context.cleanup(); } +}); diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs new file mode 100644 index 00000000..26e163d1 --- /dev/null +++ b/src/cli/loop-config-cli.mjs @@ -0,0 +1,128 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { join, parse as parsePath, relative, resolve, sep } from "node:path"; +import { readCapabilityCatalog, resolveCapability } from "../loops/capabilities/contract.mjs"; +import { trustCapability } from "../loops/capabilities/trust.mjs"; +import { doctorProfile, saveProfile, saveRoute } from "../loops/config/profiles.mjs"; +import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs"; +import { rawSha256 } from "../loops/dsl/hash.mjs"; +import { resolveUmbrella } from "./umbrella.mjs"; + +const profileUsage = "Usage: burnlist agent profile add --adapter builtin:codex-cli --binary --model --effort --authority read|write [--repo ]"; +const routeUsage = "Usage: burnlist route set --profile [--repo ]"; +const agentUsage = `${profileUsage}\n burnlist agent doctor [--repo ]`; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop run|pause|resume|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; + +function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } +function parse(tokens, allowed = []) { + const values = {}, positionals = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token.startsWith("--")) { positionals.push(token); continue; } + if (!allowed.includes(token)) fail(`Unknown option: ${token}`); + if (Object.hasOwn(values, token)) fail(`${token} must be specified at most once.`); + const value = tokens[++index]; if (!value || value.startsWith("--")) fail(`${token} requires a value.`); + values[token] = value; + } + const repo = values["--repo"] ? resolve(process.cwd(), values["--repo"]) : resolveUmbrella(process.cwd()); + return { positionals, values, repo }; +} +function requireOnly(positionals, count, usage) { if (positionals.length !== count) fail(usage); } +function canonical(value) { return `${JSON.stringify(value)}\n`; } +function catalogOrGuidance(repo) { + try { return readCapabilityCatalog(repo); } + catch (error) { + if (error?.code === "ENOENT") fail("capability catalog is missing; create .burnlist/loop-capabilities.json from the Review Loop capability example, then run inspect again", 1); + throw error; + } +} +function same(left, right) { return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size; } +function boundedNoFollow(path, maximum) { + const target = resolve(path), root = parsePath(target).root, parts = relative(root, target).split(sep), ancestors = []; let current = root; + for (const part of parts.slice(0, -1)) { + current = join(current, part); const stat = lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe path"); + ancestors.push({ path: current, stat }); + } + const leaf = lstatSync(target); + if (!leaf.isFile() || leaf.isSymbolicLink() || leaf.size > maximum) throw new Error("unsafe file"); + let fd; try { + fd = openSync(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); const opened = fstatSync(fd); + if (!opened.isFile() || !same(leaf, opened)) throw new Error("file changed"); + const bytes = Buffer.allocUnsafe(opened.size); let offset = 0; + while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset, offset); if (count <= 0) throw new Error("short read"); offset += count; } + if (!same(opened, fstatSync(fd)) || !same(opened, lstatSync(target))) throw new Error("file changed"); + for (const item of ancestors) { const stat = lstatSync(item.path); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== item.stat.dev || stat.ino !== item.stat.ino) throw new Error("path changed"); } + return bytes; + } finally { if (fd !== undefined) closeSync(fd); } +} +function boundedJson(path) { + const target = resolve(process.cwd(), path); + let bytes; try { bytes = boundedNoFollow(target, 65536); } + catch { fail("--grants must name a bounded regular no-follow JSON file", 1); } + try { return JSON.parse(bytes.toString("utf8")); } catch { fail("--grants must name a readable JSON file", 1); } +} + +export function loopConfigUsage() { return loopUsage; } +export function agentConfigUsage() { return agentUsage; } + +export async function runAgentCli(tokens, { stdout = process.stdout } = {}) { + if (tokens[0] === "--help" || tokens[0] === "-h") return { output: `${agentUsage}\n` }; + const [verb, ...tail] = tokens; + if (verb === "profile" && tail[0] === "add") { + const rest = tail.slice(1); + const parsed = parse(rest, ["--adapter", "--binary", "--model", "--effort", "--authority", "--repo"]); requireOnly(parsed.positionals, 1, profileUsage); + for (const key of ["--adapter", "--binary", "--model", "--effort", "--authority"]) if (!parsed.values[key]) fail(profileUsage); + const result = saveProfile({ repoRoot: parsed.repo, slug: parsed.positionals[0], adapter: parsed.values["--adapter"], binary: parsed.values["--binary"], model: parsed.values["--model"], effort: parsed.values["--effort"], authority: parsed.values["--authority"] }); + return { output: canonical(result), result }; + } + if (verb === "doctor") { + const parsed = parse(tail, ["--repo"]); requireOnly(parsed.positionals, 1, agentUsage); + const result = await doctorProfile({ repoRoot: parsed.repo, slug: parsed.positionals[0] }); + if (result.available) return { output: `Agent ${result.profile.id}: available\nConfiguration authority: profile record only; launch verification is deferred.\n`, result }; + return { output: `Agent ${result.profile.id}: unavailable, not ready\nREMEDIATION: ${profileUsage}\n`, result, exitCode: 1 }; + } + fail(agentUsage); +} + +export function runRouteCli(tokens) { + if (tokens[0] === "--help" || tokens[0] === "-h") return { output: `${routeUsage}\n` }; + const [verb, ...rest] = tokens; + if (verb !== "set") fail(routeUsage); + const parsed = parse(rest, ["--profile", "--repo"]); requireOnly(parsed.positionals, 1, routeUsage); + if (!parsed.values["--profile"]) fail(routeUsage); + const result = saveRoute({ repoRoot: parsed.repo, route: parsed.positionals[0], profile: parsed.values["--profile"] }); + return { output: canonical(result), result }; +} + +export async function runLoopConfigCli(tokens) { + const [kind, action, ...rest] = tokens; + if (kind === "capability" && action === "inspect") { + const parsed = parse(rest, ["--repo"]); requireOnly(parsed.positionals, 1, loopUsage); + const resolved = resolveCapability(catalogOrGuidance(parsed.repo), parsed.positionals[0]); + return { output: canonical({ schema: "burnlist-loop-capability-inspect@1", capability: resolved.policy.id, revision: resolved.revision, policyDigest: rawSha256(resolved.bytes) }), result: resolved }; + } + if (kind === "capability" && action === "trust") { + const parsed = parse(rest, ["--revision", "--grants", "--repo"]); requireOnly(parsed.positionals, 1, loopUsage); + if (!parsed.values["--revision"] || !parsed.values["--grants"]) fail(loopUsage); + const resolved = resolveCapability(catalogOrGuidance(parsed.repo), parsed.positionals[0]); + if (parsed.values["--revision"] !== resolved.revision) fail(`capability revision does not match inspected ${resolved.revision}`, 1); + const grants = boundedJson(parsed.values["--grants"]); + const result = trustCapability({ repoRoot: parsed.repo, capability: resolved.policy, grants }); + return { output: canonical(result), result }; + } + if (kind === "setup" && action === "status") { + const parsed = parse(rest, ["--repo"]); requireOnly(parsed.positionals, 0, loopUsage); + const result = await setupStatus({ repoRoot: parsed.repo }); return { output: renderSetupStatus(result), result, exitCode: result.ready ? 0 : 1 }; + } + fail(loopUsage); +} + +export function writeCliResult(value, stdout = process.stdout) { stdout.write(value.output); return value.result ?? null; } +export async function runAgentCliEntry(tokens = process.argv.slice(3)) { + try { const value = await runAgentCli(tokens); writeCliResult(value); process.exitCode = value.exitCode ?? 0; return value.result ?? null; } + catch (error) { process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); process.exitCode = error?.exitCode ?? 1; return null; } +} +export async function runRouteCliEntry(tokens = process.argv.slice(3)) { + try { const value = runRouteCli(tokens); writeCliResult(value); process.exitCode = value.exitCode ?? 0; return value.result ?? null; } + catch (error) { process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); process.exitCode = error?.exitCode ?? 1; return null; } +} diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs new file mode 100644 index 00000000..11b55d54 --- /dev/null +++ b/src/cli/loop-runtime-cli.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync } from "node:child_process"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; +import { runLoopCli } from "./loop-cli.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; +import { createProductionRun } from "../loops/run/binder.mjs"; +import { fixtureRunId } from "../loops/run/run-test-fixtures.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cli = join(root, "bin", "burnlist.mjs"); +function command(repo, args, env = {}) { + const result = spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8", env: { ...process.env, ...env } }); + assert.equal(result.stderr, "", `${args.join(" ")}: ${result.stderr}`); assert.equal(result.status, 0, `${args.join(" ")}: ${result.stdout}`); return result.stdout; +} +function created(repo) { return JSON.parse(command(repo, ["create", fixtureItemRef])).runId; } + +test("real CLI control reads are stable, list is absent-state read-only, and stored authority drives run/resume", (t) => { + const directory = mkdtempSync(join(tmpdir(), "m6-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runs = join(repo, ".local", "burnlist", "loop", "m2", "runs"); + assert.equal(command(repo, ["list"]), "[]\n"); assert.equal(existsSync(runs), false); + const first = created(repo), status = command(repo, ["status", first]), inspect = command(repo, ["inspect", first]); + for (const publicView of [JSON.parse(status), JSON.parse(inspect)]) { + assert.equal(publicView.loopId, "review"); + assert.match(publicView.loopRevision, /^er1-sha256:[a-f0-9]{64}$/u); + assert.equal(Number.isSafeInteger(publicView.createdAt), true); + assert.equal(Number.isSafeInteger(publicView.updatedAt), true); + } + assert.equal(command(repo, ["status", first]), status); assert.equal(command(repo, ["inspect", first]), inspect); + const authority = join(runs, Buffer.from(first).toString("hex"), "dispatch-authority.json"), bytes = readFileSync(authority); + const counter = join(directory, "counter"); writeFileSync(counter, "0"); + const completed = JSON.parse(command(repo, ["run", first], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" })); + assert.equal(completed.state, "converged"); assert.deepEqual(readFileSync(authority), bytes); + const blocked = spawnSync(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(blocked.status, 1); assert.match(blocked.stderr, /current Run is still executable/u); +}); + +test("real CLI fences active control and proof-gates reconcile", (t) => { + const directory = mkdtempSync(join(tmpdir(), "m6-cli-fence-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import{runStore}from${JSON.stringify(new URL("../loops/run/run-store.mjs", import.meta.url).href)};const s=runStore(process.argv[1]),a=s.acquireLease(process.argv[2]);s.append(process.argv[2],a.lease,"node-started",{nodeId:"implement",attempt:1});s.append(process.argv[2],a.lease,"invocation-started",{nodeId:"implement",attempt:1,invocationId:"${"a".repeat(32)}"});process.stdout.write(a.recoveryProof);`, repo, runId], { cwd: repo, encoding: "utf8" }); + assert.equal(held.status, 0, held.stderr); + const result = spawnSync(process.execPath, [cli, "loop", "pause", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(result.status, 1); assert.match(result.stderr, /active foreground owner/u); + const reconcile = spawnSync(process.execPath, [cli, "loop", "reconcile", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(reconcile.status, 1); assert.match(reconcile.stderr, /not demonstrably lost/u); + assert.equal(JSON.parse(command(repo, ["reconcile", runId, "--recovery-proof", held.stdout])).state, "needs-human"); +}); + +test("loop complete is the public, idempotent completion command", (t) => { + const directory = mkdtempSync(join(tmpdir(), "m8-cli-complete-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), counter = join(directory, "counter"); + writeFileSync(counter, "0"); assert.equal(JSON.parse(command(repo, ["run", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" })).state, "converged"); + assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, false); + assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true); +}); + +test("a stopped current Run permits a safe replacement, while an executable Run does not", (t) => { + const directory = mkdtempSync(join(tmpdir(), "m8-current-replace-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), first = created(repo); + assert.equal(JSON.parse(command(repo, ["stop", first])).state, "stopped"); + const second = created(repo); assert.notEqual(second, first); + const old = spawnSync(process.execPath, [cli, "loop", "run", first, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(old.status, 1); assert.match(old.stderr, /superseded and cannot launch/u); +}); + +test("ordinary CLI create recovers an unpublished current reservation without requiring its RunRef", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "m12-cli-create-retry-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const cut = runStore(repo, { hooks: { beforeRunPublish() { throw new Error("publication cut"); } } }); + await assert.rejects(createProductionRun({ repoRoot: repo, store: cut, itemRef: fixtureItemRef, runId: fixtureRunId }), /publication cut/u); + assert.equal(cut.readCurrentRun(fixtureItemRef).runId, fixtureRunId); + assert.equal(JSON.parse(command(repo, ["create", fixtureItemRef])).runId, fixtureRunId); +}); + +test("concurrent replacement creates exactly one executable current Run", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "m8-current-race-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), first = created(repo); + command(repo, ["stop", first]); + const launch = () => new Promise((resolvePromise) => { + const child = spawn(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); let stdout = "", stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("close", (status) => resolvePromise({ status, stdout, stderr })); + }); + const results = await Promise.all([launch(), launch()]); + assert.equal(results.filter((result) => result.status === 0).length, 1); assert.equal(results.filter((result) => result.status === 1).length, 1); + const current = runStore(repo).readCurrentRun(fixtureItemRef); assert.equal(current.runId, JSON.parse(results.find((result) => result.status === 0).stdout).runId); +}); + +test("a second CLI SIGINT reaches controlled stop before the foreground runner settles", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "m6-cli-signal-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const processObject = new EventEmitter(), calls = []; + let settle; + const pending = new Promise((resolvePromise) => { settle = resolvePromise; }); + const store = runStore(repo); + const runner = { + requestPause() { calls.push("pause"); }, + requestStop() { calls.push("stop"); }, + async run() { await pending; return store.read(runId); }, + }; + const output = { value: "", write(chunk) { this.value += chunk; } }; + const commandPromise = runLoopCli(["run", runId, "--repo", repo], { + processObject, + runnerFor: () => runner, + stdout: output, + }); + processObject.emit("SIGINT"); + processObject.emit("SIGINT"); + assert.deepEqual(calls, ["pause", "stop"]); + assert.equal(processObject.listenerCount("SIGINT"), 1); + settle(); + await commandPromise; + assert.equal(processObject.listenerCount("SIGINT"), 0); + assert.equal(JSON.parse(output.value).runId, runId); +}); diff --git a/src/server/dir-lock.mjs b/src/server/dir-lock.mjs index 194c0ab5..427d1697 100644 --- a/src/server/dir-lock.mjs +++ b/src/server/dir-lock.mjs @@ -14,6 +14,7 @@ import { writeSync, } from "node:fs"; import os from "node:os"; +import { execFileSync } from "node:child_process"; import { basename, dirname, join } from "node:path"; export const LOCK_MAX_AGE_MS = 15 * 60_000; @@ -30,6 +31,14 @@ const safeTime = (value) => Number.isFinite(value) && Number.isInteger(value) && const sleepSync = (milliseconds) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); const defaultFs = { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeSync }; +export function processStartIdentity(pid, { exec = execFileSync, environment = process.env } = {}) { + try { + const value = exec("ps", ["-o", "lstart=", "-p", String(pid)], + { encoding: "utf8", timeout: 1000, maxBuffer: 4096, env: { ...environment, LC_ALL: "C", LANG: "C" } }).trim().replace(/\s+/gu, " "); + return value || null; + } catch { return null; } +} + function ignored(error) { return error?.code === "ENOENT"; } @@ -75,8 +84,9 @@ function parseRecord(text) { } function validV2(record, token) { - return record?.version === 1 && safePid(record.pid) && typeof record.hostname === "string" && record.hostname !== "" - && tokenPattern.test(record.token) && record.token === token && safeTime(record.createdAt); + return [1, 2].includes(record?.version) && safePid(record.pid) && typeof record.hostname === "string" && record.hostname !== "" + && tokenPattern.test(record.token) && record.token === token && safeTime(record.createdAt) + && (record.version === 1 || record.startedAt === null || typeof record.startedAt === "string" && record.startedAt.length > 0 && record.startedAt.length <= 128); } function legacyRecord(record) { @@ -87,18 +97,23 @@ function legacyRecord(record) { return !Object.hasOwn(record, "hostname") ? record : null; } -function stale(record, now, hostname, pidProbe) { +function stale(record, now, hostname, pidProbe, reclaimLiveAfterAge, processIdentity) { if (record.hostname !== hostname) return false; - return staleSameHost(record, now, pidProbe); + return staleSameHost(record, now, pidProbe, reclaimLiveAfterAge, processIdentity); } -function staleSameHost(record, now, pidProbe) { +function staleSameHost(record, now, pidProbe, reclaimLiveAfterAge = true, processIdentity = () => null) { let live = true; try { pidProbe(record.pid); } catch (error) { live = error?.code !== "ESRCH"; } - return !live || now - record.createdAt >= LOCK_MAX_AGE_MS; + if (!live) return true; + if (record.version === 2 && record.startedAt !== null) { + let identity = null; try { identity = processIdentity(record.pid); } catch { return false; } + if (identity !== null && identity !== record.startedAt) return true; + } + return reclaimLiveAfterAge && now - record.createdAt >= LOCK_MAX_AGE_MS; } -function inspectCanonical(fs, lockPath, { now, hostname, pidProbe, readFile }) { +function inspectCanonical(fs, lockPath, { now, hostname, pidProbe, readFile, reclaimLiveAfterAge, processIdentity }) { const inspectionNow = now(); let stat; try { stat = fs.lstatSync(lockPath); } catch (error) { @@ -129,7 +144,7 @@ function inspectCanonical(fs, lockPath, { now, hostname, pidProbe, readFile }) { throw error; } if (!validV2(record, match[1])) return { kind: "corrupt" }; - return { kind: "v2", record, token: match[1], stale: stale(record, inspectionNow, hostname, pidProbe) }; + return { kind: "v2", record, token: match[1], stale: stale(record, inspectionNow, hostname, pidProbe, reclaimLiveAfterAge, processIdentity) }; } if (!stat.isFile()) return { kind: "corrupt" }; let text; @@ -140,7 +155,7 @@ function inspectCanonical(fs, lockPath, { now, hostname, pidProbe, readFile }) { const record = legacyRecord(parseRecord(text)); if (!record) return { kind: "corrupt" }; // Only v2 directory records use hostname as a cross-host safety boundary. - return { kind: "legacy", record, text, stale: staleSameHost(record, inspectionNow, pidProbe) }; + return { kind: "legacy", record, text, stale: staleSameHost(record, inspectionNow, pidProbe, reclaimLiveAfterAge) }; } function buildCandidate(fs, lockPath, context) { @@ -161,7 +176,7 @@ function buildCandidate(fs, lockPath, context) { try { hooks.afterCandidateDirectory?.({ candidate, token: value }); const path = join(candidate, ownerName(value)); - const record = `${JSON.stringify({ version: 1, pid: process.pid, hostname, token: value, createdAt })}\n`; + const record = `${JSON.stringify({ version: 2, pid: process.pid, hostname, token: value, createdAt, startedAt: context.processIdentity(process.pid) })}\n`; const bytes = Buffer.from(record, "utf8"); const fd = fs.openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); let primary; @@ -236,13 +251,14 @@ function gc(fs, lockPath, context) { } /** Test seams are deliberately optional; production callers use only lockPath, fn, and errorFactory. */ -export function withDirectoryLock({ lockPath, fn, errorFactory, adapters = {}, hooks = {} }) { +export function withDirectoryLock({ lockPath, fn, errorFactory, adapters = {}, hooks = {}, reclaimLiveAfterAge = true }) { const fs = { ...defaultFs, ...adapters.fs }; const context = { hostname: adapters.hostname?.() ?? os.hostname(), now: adapters.now ?? Date.now, pidProbe: adapters.pidProbe ?? ((pid) => process.kill(pid, 0)), sleep: adapters.sleep ?? sleepSync, + processIdentity: adapters.processIdentity ?? processStartIdentity, token: adapters.token ?? (() => randomBytes(32).toString("hex")), logger: adapters.logger ?? (() => {}), - readFile: adapters.readFileSync ?? readFileSync, hooks, + readFile: adapters.readFileSync ?? readFileSync, hooks, reclaimLiveAfterAge, }; if (typeof context.hostname !== "string" || context.hostname === "") throw new Error("Cannot create a lock without a hostname."); fs.mkdirSync(dirname(lockPath), { recursive: true }); diff --git a/src/server/dir-lock.test.mjs b/src/server/dir-lock.test.mjs index 8bce65be..a3a7cabd 100644 --- a/src/server/dir-lock.test.mjs +++ b/src/server/dir-lock.test.mjs @@ -3,7 +3,7 @@ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSyn import { hostname, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import { CANDIDATE_GC_AGE_MS, LOCK_MAX_AGE_MS, MAX_ATTEMPTS, RETRY_DELAY_MS, withDirectoryLock } from "./dir-lock.mjs"; +import { CANDIDATE_GC_AGE_MS, LOCK_MAX_AGE_MS, MAX_ATTEMPTS, RETRY_DELAY_MS, processStartIdentity, withDirectoryLock } from "./dir-lock.mjs"; const token = (letter) => letter.repeat(64); @@ -13,9 +13,10 @@ function paths(t) { return [join(root, ".lock"), join(root, "roots.lock")]; } -function writeV2(lock, { pid = process.pid, host = hostname(), value = token("a"), createdAt = Date.now() } = {}) { +function writeV2(lock, { pid = process.pid, host = hostname(), value = token("a"), createdAt = Date.now(), version = 1, startedAt } = {}) { mkdirSync(lock, { recursive: true }); - writeFileSync(join(lock, `owner-${value}.json`), `${JSON.stringify({ version: 1, pid, hostname: host, token: value, createdAt })}\n`); + writeFileSync(join(lock, `owner-${value}.json`), `${JSON.stringify({ version, pid, hostname: host, token: value, createdAt, + ...(version === 2 ? { startedAt: startedAt ?? "Mon Jan 01 00:00:00 2024" } : {}) })}\n`); } function writeLegacy(lock, { pid = process.pid, value = "a".repeat(24), createdAt = Date.now() } = {}) { @@ -104,6 +105,38 @@ test("stale age boundary and foreign-host protection are exact", (t) => { }); }); +test("identity-held locks can disable age-only reclamation for bounded Run work", (t) => { + forBoth(t, "identity held", (lock) => { + const now = 2_000_000; writeV2(lock, { createdAt: now - 10 * LOCK_MAX_AGE_MS }); + assert.throws(() => withDirectoryLock({ lockPath: lock, fn: () => assert.fail("live owner must retain lock"), + reclaimLiveAfterAge: false, adapters: { now: () => now, pidProbe: () => {}, sleep: () => {} }, + errorFactory: () => Object.assign(new Error("locked"), { code: "ELOCKED" }) }), { code: "ELOCKED" }); + }); +}); + +test("v2 process identity distinguishes PID reuse while matching, unverifiable, and foreign owners remain held", (t) => { + const [lock] = paths(t), common = { version: 2, pid: 4242, startedAt: "Mon Jan 01 00:00:00 2024", createdAt: Date.now() }; + writeV2(lock, common); + assert.equal(acquire(lock, () => "reclaimed", { pidProbe: () => {}, processIdentity: () => "Tue Jan 02 00:00:00 2024", sleep: () => {} }), "reclaimed"); + for (const observed of [() => common.startedAt, () => null, () => { throw new Error("unavailable"); }]) { + writeV2(lock, common); + const processIdentity = (pid) => pid === common.pid ? observed() : "candidate"; + assert.throws(() => acquire(lock, () => assert.fail("must remain held"), { pidProbe: () => {}, processIdentity, sleep: () => {} }), { code: "ELOCKED" }); + rmSync(lock, { recursive: true, force: true }); + } + writeV2(lock, { ...common, host: "foreign-host" }); + assert.throws(() => acquire(lock, () => assert.fail("foreign owner must remain held"), + { pidProbe: () => { throw Object.assign(new Error("gone"), { code: "ESRCH" }); }, processIdentity: () => "different", sleep: () => {} }), { code: "ELOCKED" }); +}); + +test("process start identity pins the ps locale and normalizes output independently of parent locale", () => { + let observed; + const value = processStartIdentity(42, { environment: { LANG: "de_DE.UTF-8", LC_ALL: "fr_FR.UTF-8", KEEP: "yes" }, + exec(command, argv, options) { observed = { command, argv, options }; return " Mon Jan 01 00:00:00 2024 \n"; } }); + assert.equal(value, "Mon Jan 01 00:00:00 2024"); + assert.deepEqual(observed.options.env, { LANG: "C", LC_ALL: "C", KEEP: "yes" }); +}); + test("bounded contention performs 100 publications and 99 sleeps", (t) => { forBoth(t, "bounds", (lock) => { writeV2(lock); diff --git a/src/server/plan-model.mjs b/src/server/plan-model.mjs index 7b510049..e6f99546 100644 --- a/src/server/plan-model.mjs +++ b/src/server/plan-model.mjs @@ -1,5 +1,6 @@ import { basename, dirname, normalize, relative, resolve } from "node:path"; import { readTextFileWithLimit, safeStat } from "./fs-safe.mjs"; +import { findLoopMetadata, locateItemSpan } from "../loops/assignment/item-metadata.mjs"; export function twoDigit(value) { return String(value).padStart(2, "0"); @@ -76,6 +77,13 @@ export function parseActiveItems(lines) { return items; } +/** Read-only exact-span helper for Loop-aware observers; it never normalizes markdown. */ +export function loopAssignmentForItem(markdown, itemId) { + const located = locateItemSpan(markdown, itemId); + const metadata = findLoopMetadata(located); + return metadata ? { ...metadata.values, itemStart: located.startByte, itemEnd: located.endByte } : null; +} + export function parseCompleted(lines) { const completed = []; const malformed = []; From 48cb6739211782b6562ad403cb099ef34ed4d54e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:07 +0200 Subject: [PATCH 03/23] feat: show live loop runs in the checklist dashboard --- .../ChecklistDashboard.test.mjs | 74 +++++++++- .../ChecklistDashboard/ChecklistDashboard.tsx | 48 +++++- .../ChecklistDashboard/ChecklistOvenView.tsx | 3 +- .../checklist-dom-golden.test.mjs | 87 +++++++++++ .../checklist-loop-progression.golden.json | 7 + .../checklist-loop-states.golden.json | 10 ++ dashboard/src/hooks/dashboard-data.mjs | 22 ++- dashboard/src/hooks/dashboard-data.test.mjs | 73 ++++++++- dashboard/src/hooks/useDashboardData.ts | 18 ++- dashboard/src/lib/types.ts | 34 +++++ src/server/burnlist-dashboard-server.mjs | 50 ++++++- src/server/dashboard-routes-fixtures.mjs | 2 +- src/server/run-routes.test.mjs | 139 +++++++++++++++++- 13 files changed, 551 insertions(+), 16 deletions(-) create mode 100644 dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json create mode 100644 dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index 487f4637..a588c14e 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -6,6 +6,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { build } from "esbuild"; import { checklistFixture as data } from "./ChecklistDashboard.fixture.mjs"; +import { runM4ProgressFixture } from "../../../../src/loops/run/run-test-fixtures.mjs"; const componentPath = new URL("./ChecklistDashboard.tsx", import.meta.url).pathname; const stylesheetPath = new URL("./ChecklistDashboard.css", import.meta.url).pathname; @@ -19,14 +20,17 @@ test("checklist progress owns its workspace height instead of inheriting the dif test("checklist detail renders the split progress surface and event card list", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".checklist-dashboard-test-")); + let repoRoot; try { + repoRoot = await mkdtemp(join(process.cwd(), ".m5-loop-ui-")); const outputPath = join(outputDir, "ChecklistDashboard.mjs"); await build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", }); - const { ChecklistDashboard, checklistEventDetailFields } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); + const { ChecklistDashboard, LoopRunPanel, checklistEventDetailFields } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); const markup = renderToStaticMarkup(createElement(ChecklistDashboard, { data })); + assert.equal(markup, renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...data, loopRun: null } }))); assert.match(markup, /aria-label="Burnlist progress KPIs"/u); assert.match(markup, /class="driving-parity-kpi-item driving-parity-kpi-section checklist-kpi-current"/u); @@ -68,7 +72,75 @@ test("checklist detail renders the split progress surface and event card list", assert.doesNotMatch(markup, /]*>Changes<\/button>/u); assert.doesNotMatch(markup, /Burnlist detail view/u); assert.doesNotMatch(markup, /Repo Graph/u); + + const { snapshots } = await runM4ProgressFixture({ + repoRoot, + outcomes: ["complete", "pass", "reject", "complete", "pass", "approve"], + }); + for (const projection of snapshots) { + const stage = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, loopRun: projection } })); + assert.match(stage, new RegExp(`Current ${projection.currentNode} · attempt ${projection.attempt} · cycle ${projection.cycle}`, "u")); + assert.match(stage, /aria-label="Loop graph edges"/u); + assert.match(stage, /implement<\/strong> —complete→<\/span> verify<\/strong>/u); + assert.match(stage, /aria-current="step"/u); + if (projection.latestResult) assert.match(stage, new RegExp(`Latest ${projection.latestResult.kind} · ${projection.latestResult.summary}`, "u")); + if (projection.currentNode === "implement" && projection.attempt === 2) assert.match(stage, /review —reject→<\/span> implement/u); + if (projection.currentNode === "converged") assert.match(stage, /review —approve→<\/span> converged/u); + if (projection.currentNode === "completed") assert.match(stage, /converged —pass→<\/span> completed/u); + } + const evidence = renderToStaticMarkup(createElement(LoopRunPanel, { data: { + ...data, + loopRun: { + ...snapshots.at(-1), + latestMaker: { summary: "candidate prepared", at: Date.parse("2026-07-15T11:40:00Z"), candidateId: "candidate-1" }, + latestCheck: { summary: "verify passed", at: Date.parse("2026-07-15T11:45:00Z"), candidateId: "candidate-1" }, + latestReviewer: { summary: "approved", at: Date.parse("2026-07-15T11:50:00Z"), candidateId: "candidate-1" }, + }, + } })); + assert.match(evidence, /aria-label="Latest role evidence"/u); + assert.match(evidence, /
Maker<\/dt>
candidate prepared/u); + assert.match(evidence, /
Check<\/dt>
verify passed/u); + assert.match(evidence, /
Reviewer<\/dt>
approved/u); + assert.match(evidence, /candidate candidate-1/u); } finally { await rm(outputDir, { force: true, recursive: true }); + if (repoRoot) await rm(repoRoot, { force: true, recursive: true }); } }); + +test("Loop panel exposes every terminal and observer diagnostic state accessibly", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".m7-loop-state-ui-")); + const repoRoot = await mkdtemp(join(process.cwd(), ".m7-loop-state-run-")); + try { + const outputPath = join(outputDir, "ChecklistDashboard.mjs"); + await build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }); + const { LoopRunPanel } = await import(`${new URL(`file://${outputPath}`).href}?states=${Date.now()}`); + const { final } = await runM4ProgressFixture({ repoRoot, outcomes: ["complete", "pass", "reject", "complete", "pass", "approve"] }); + const labels = { paused: "Paused", failed: "Failed", stopped: "Stopped", "needs-human": "Needs human review", "budget-exhausted": "Budget exhausted", converged: "Converged", completed: "Completed", corrupt: "Corrupt projection", stale: "Stale projection" }; + for (const [state, label] of Object.entries(labels)) { + const projection = { ...final, state }; + const markup = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, loopRun: state === "stale" ? { ...projection, diagnostic: "stale" } : projection } })); + assert.match(markup, new RegExp(`aria-label="Loop state: ${label}"`, "u")); + assert.match(markup, /aria-label="Loop budget"/u); + } + } finally { + await rm(outputDir, { force: true, recursive: true }); + await rm(repoRoot, { force: true, recursive: true }); + } +}); + +test("Loop panel exposes a real unreachable-projection diagnostic without fabricating a Run", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".m12-loop-diagnostic-")); + try { + const outputPath = join(outputDir, "ChecklistDashboard.mjs"); + await build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }); + const { LoopRunPanel } = await import(`${new URL(`file://${outputPath}`).href}?diagnostic=${Date.now()}`); + const markup = renderToStaticMarkup(createElement(LoopRunPanel, { data: { + ...data, loopProjectionDiagnostic: "corrupt", loopProjectionMessage: "Loop projection is unavailable; retaining the last verified projection.", loopRun: null, + } })); + assert.match(markup, /aria-label="Loop run diagnostic"/u); + assert.match(markup, /role="alert"/u); + assert.match(markup, /Corrupt projection/u); + assert.doesNotMatch(markup, /Current<\/strong>/u); + } finally { await rm(outputDir, { force: true, recursive: true }); } +}); diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index afd971a9..27ca8de1 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -94,10 +94,56 @@ export function EventCardList({ data }: { data: ChecklistProgressData }) { })}{!rows.length &&

No completed events yet.

}; } +export function LoopRunPanel({ data }: { data: ChecklistProgressData }) { + const run = data.loopRun; + if (!run) return data.loopProjectionDiagnostic ?
+
Loop Run
Corrupt projection
+

{data.loopProjectionMessage || "The Loop projection is corrupt. Progress remains available while the dashboard waits for a verified projection."}

+
: null; + const stateLabel: Record = { + paused: "Paused", failed: "Failed", stopped: "Stopped", "needs-human": "Needs human review", + "budget-exhausted": "Budget exhausted", corrupt: "Corrupt projection", stale: "Stale projection", converged: "Converged", completed: "Completed", + }; + const state = run.diagnostic === "corrupt" ? "Corrupt projection" : run.diagnostic === "stale" ? "Stale projection" : stateLabel[run.state] ?? run.state; + const budget = run.budget; + const nodeById = new Map(run.graph.nodes.map((node) => [node.id, node])); + const orderedEdges = [...run.graph.edges].sort((left, right) => { + const stage = (edge: typeof left) => edge.from === "implement" ? 0 : edge.from === "verify" ? 1 : edge.from === "review" ? 2 : edge.from === "converged" ? 3 : 4; + return stage(left) - stage(right) || left.from.localeCompare(right.from) || left.on.localeCompare(right.on); + }); + const roleResults = [ + ["Maker", run.latestMaker], ["Check", run.latestCheck], ["Reviewer", run.latestReviewer], + ] as const; + return
+
Loop Run
+ {state}
+

{run.loopId}{run.loopRevision && {run.loopRevision}}

+
Current {run.currentNode} · attempt {run.attempt} · cycle {run.cycle}
+
+
Elapsed
{formatDuration(budget.elapsedMilliseconds)}
+
Rounds
{budget.counters.rounds}/{budget.limits.maxRounds}
+
Agent runs
{budget.counters.agentRuns}/{budget.limits.maxAgentRuns}
+
Checks
{budget.counters.checkRuns}/{budget.limits.maxCheckRuns}
+
Transitions
{budget.counters.transitions}/{budget.limits.maxTransitions}
+
+
    {run.graph.nodes.map((node) => +
  1. + {node.id}{node.kind} +
  2. )}
+
    {orderedEdges.map((edge) => +
  1. {nodeById.get(edge.from)?.id ?? edge.from} —{edge.on}→ {nodeById.get(edge.to)?.id ?? edge.to}
  2. )}
+ {roleResults.some(([, result]) => result) &&
{roleResults.map(([role, result]) => result && +
{role}
{result.summary} {result.candidateId && candidate {result.candidateId}}
)}
} + {run.latestResult &&

Latest {run.latestResult.kind} · {run.latestResult.summary}

} +
    {run.transitions.map((transition) => +
  1. {transition.from} —{transition.outcome}→ {transition.to}
  2. )}
+
; +} + export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { useEffect(() => { document.body.classList.add("driving-parity-view", "checklist-detail-view"); return () => document.body.classList.remove("driving-parity-view", "checklist-detail-view"); }, []); - return
; + return
; } diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx index 65e8dfb1..9c518aac 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx @@ -3,6 +3,7 @@ import type { ResolvedOvenIr } from "@hooks"; import type { ChecklistProgressData } from "@lib"; import { adaptChecklist } from "@lib/checklist-adapter"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; +import { LoopRunPanel } from "./ChecklistDashboard"; import "./ChecklistDashboard.css"; export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; ir: ResolvedOvenIr }) { @@ -11,5 +12,5 @@ export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; i document.body.classList.add("driving-parity-view", "checklist-detail-view"); return () => document.body.classList.remove("driving-parity-view", "checklist-detail-view"); }, []); - return ; + return <>; } diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs index 1e45dc91..e6050477 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; @@ -7,12 +8,16 @@ import { renderToStaticMarkup } from "react-dom/server"; import { build } from "esbuild"; import { checklistFixture } from "./ChecklistDashboard.fixture.mjs"; import { withDeterministicTime } from "../../oven/test-support/deterministic-time.mjs"; +import { runM4ProgressFixture } from "../../../../src/loops/run/run-test-fixtures.mjs"; const componentPath = new URL("./ChecklistDashboard.tsx", import.meta.url).pathname; const normalizerPath = new URL("../../oven/test-support/dom-normalize.ts", import.meta.url).pathname; const libPath = new URL("../../lib", import.meta.url).pathname; const ovenPath = new URL("../../oven", import.meta.url).pathname; const goldenPath = new URL("./checklist-dom.golden.html", import.meta.url); +const loopGoldenPath = new URL("./checklist-loop-progression.golden.json", import.meta.url); +const loopStateGoldenPath = new URL("./checklist-loop-states.golden.json", import.meta.url); +const digest = (value) => createHash("sha256").update(value).digest("hex"); test("checklist detail static DOM matches the frozen byte golden", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".checklist-dom-golden-test-")); @@ -35,3 +40,85 @@ test("checklist detail static DOM matches the frozen byte golden", async () => { await rm(outputDir, { force: true, recursive: true }); } }); + +test("real M4 projections advance the full Checklist DOM through the frozen Loop progression", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".checklist-loop-golden-test-")); + const repoRoot = await mkdtemp(join(process.cwd(), ".checklist-loop-run-")); + try { + const componentOutput = join(outputDir, "ChecklistDashboard.mjs"); + const normalizerOutput = join(outputDir, "dom-normalize.mjs"); + await Promise.all([ + build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: componentOutput, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }), + build({ entryPoints: [normalizerPath], bundle: true, format: "esm", outfile: normalizerOutput, platform: "node", target: "node18" }), + ]); + const [{ ChecklistDashboard }, { normalize, parseHtml, serializeCanonical }] = await Promise.all([ + import(`${new URL(`file://${componentOutput}`).href}?test=${Date.now()}`), + import(`${new URL(`file://${normalizerOutput}`).href}?test=${Date.now()}`), + ]); + const { snapshots } = await runM4ProgressFixture({ + repoRoot, + outcomes: ["complete", "pass", "reject", "complete", "pass", "approve"], + }); + const selected = [ + snapshots.find((snapshot) => snapshot.currentNode === "implement" && snapshot.attempt === 1 && snapshot.latestResult?.kind === "reject"), + snapshots.find((snapshot) => snapshot.currentNode === "implement" && snapshot.attempt === 2 && snapshot.latestResult?.kind === "reject"), + snapshots.find((snapshot) => snapshot.currentNode === "review" && snapshot.attempt === 2 && snapshot.latestResult?.kind === "approve"), + snapshots.find((snapshot) => snapshot.currentNode === "converged" && snapshot.attempt === 1), + snapshots.filter((snapshot) => snapshot.currentNode === "completed").at(-1), + ]; + assert.equal(selected.every(Boolean), true); + const actual = selected.map((projection) => { + const projectionBytes = JSON.stringify({ ...projection, revision: "" }); + const markup = withDeterministicTime(() => + renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, loopRun: projection } }))); + const domBytes = serializeCanonical(normalize(parseHtml(markup))); + return { + checkpoint: `${projection.currentNode}/${projection.attempt}/${projection.latestResult?.kind ?? "none"}`, + projectionBytes: Buffer.byteLength(projectionBytes), + projectionSha256: digest(projectionBytes), + domBytes: Buffer.byteLength(domBytes), + domSha256: digest(domBytes), + }; + }); + const expected = JSON.parse(await readFile(loopGoldenPath, "utf8")); + assert.deepEqual(actual, expected); + } finally { + await Promise.all([ + rm(outputDir, { force: true, recursive: true }), + rm(repoRoot, { force: true, recursive: true }), + ]); + } +}); + +test("terminal, paused, stale, and corrupt Loop states retain frozen full Checklist DOMs", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".checklist-loop-state-golden-test-")); + const repoRoot = await mkdtemp(join(process.cwd(), ".checklist-loop-state-run-")); + try { + const componentOutput = join(outputDir, "ChecklistDashboard.mjs"); + const normalizerOutput = join(outputDir, "dom-normalize.mjs"); + await Promise.all([ + build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: componentOutput, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18" }), + build({ entryPoints: [normalizerPath], bundle: true, format: "esm", outfile: normalizerOutput, platform: "node", target: "node18" }), + ]); + const [{ ChecklistDashboard }, { normalize, parseHtml, serializeCanonical }] = await Promise.all([ + import(`${new URL(`file://${componentOutput}`).href}?test=${Date.now()}`), + import(`${new URL(`file://${normalizerOutput}`).href}?test=${Date.now()}`), + ]); + const { final } = await runM4ProgressFixture({ repoRoot, outcomes: ["complete", "pass", "approve"] }); + const variants = [ + ["paused", { state: "paused" }], ["failed", { state: "failed" }], ["stopped", { state: "stopped" }], + ["needs-human", { state: "needs-human" }], ["exhausted", { state: "budget-exhausted" }], + ["stale", { diagnostic: "stale" }], ["corrupt", { state: "corrupt" }], ["completed", { state: "converged" }], + ]; + const actual = variants.map(([checkpoint, patch]) => { + const markup = withDeterministicTime(() => renderToStaticMarkup(createElement(ChecklistDashboard, { + data: { ...checklistFixture, loopRun: { ...final, ...patch } }, + }))); + const domBytes = serializeCanonical(normalize(parseHtml(markup))); + return { checkpoint, domBytes: Buffer.byteLength(domBytes), domSha256: digest(domBytes) }; + }); + assert.deepEqual(actual, JSON.parse(await readFile(loopStateGoldenPath, "utf8"))); + } finally { + await Promise.all([rm(outputDir, { force: true, recursive: true }), rm(repoRoot, { force: true, recursive: true })]); + } +}); diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json new file mode 100644 index 00000000..2729a30a --- /dev/null +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -0,0 +1,7 @@ +[ + { "checkpoint": "implement/1/reject", "projectionBytes": 1855, "projectionSha256": "bb919c8828f97eb63e5c6e29b2eb73736e3a24e476ea13190d39ac4fc329fd3c", "domBytes": 12119, "domSha256": "70d9d519964b9f9b292bf02bed768355e95eb126b6ba254cd7562ba3825855ab" }, + { "checkpoint": "implement/2/reject", "projectionBytes": 1855, "projectionSha256": "9c623f1ccf612c9921f93c031ac4c7757892d2eda3feb46c34dfbd6347957b94", "domBytes": 12119, "domSha256": "cd341ac15a227617969bdb3e66cd9d23fef58075f6c31e26b35b78efdae49270" }, + { "checkpoint": "review/2/approve", "projectionBytes": 1990, "projectionSha256": "318079e10fef419d1985aa704b8c5bb77791161a57b6771dc6d5cde5e005ddea", "domBytes": 12214, "domSha256": "beb873ef0164fe58e3b635a3082dcebb2f191882ea8992b6aa71c32283154683" }, + { "checkpoint": "converged/1/approve", "projectionBytes": 2062, "projectionSha256": "1a2c67ec6c1645b46c690d0531a32b68b679191531f6fa6a5cf333f66ffcf967", "domBytes": 12267, "domSha256": "7427a07760115ac129912c66dcb03fd619051b7fa5d69e08f352a1c99a9251a5" }, + { "checkpoint": "completed/1/approve", "projectionBytes": 2133, "projectionSha256": "c48da3b51874c7daba7669a4ded690445d89b9eb1b7b311f061b60472f54cc07", "domBytes": 12321, "domSha256": "1ff2c2747b11031e8c3d89fbf9ad15d87bfe5f292c57161123685fd62ca0f83b" } +] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json new file mode 100644 index 00000000..44ed019c --- /dev/null +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -0,0 +1,10 @@ +[ + { "checkpoint": "paused", "domBytes": 12171, "domSha256": "46388c6ac8e544fbf4b511f0178c2d0adfbc8cc41c6a37375cce7e7b0f4b20c8" }, + { "checkpoint": "failed", "domBytes": 12171, "domSha256": "de66441d38526255a307f54d4eead2c5d0e0d3d2f58d70a0a00e0f8614ee26b0" }, + { "checkpoint": "stopped", "domBytes": 12173, "domSha256": "e306f86ff9bbcc7294e6b9bc3df3987989f796bd947d789f4d1edddca83efa9f" }, + { "checkpoint": "needs-human", "domBytes": 12195, "domSha256": "8a25e97a8f6fee72f336ab4ecbc859964d0b4ff664e63e32efba0e57a83d31c5" }, + { "checkpoint": "exhausted", "domBytes": 12191, "domSha256": "46df3aa30b14a0086f8299895e69fad120e6564bf65b2119cac652b6ec793c10" }, + { "checkpoint": "stale", "domBytes": 12191, "domSha256": "afc4e0cebcd3a7f3bb1fad8d32ae733b9d2bc9c2a0a9910bc1ad37ff39fc82f9" }, + { "checkpoint": "corrupt", "domBytes": 12195, "domSha256": "b6540a7baf33695218d768a9183c89b13c237f677ef825888270ce80598f23aa" }, + { "checkpoint": "completed", "domBytes": 12177, "domSha256": "27ef2ca6d8357bd24166c1954220e3f7c75f60361e574f16806a72fa3b9d2ace" } +] diff --git a/dashboard/src/hooks/dashboard-data.mjs b/dashboard/src/hooks/dashboard-data.mjs index c6a6c773..ba3220fa 100644 --- a/dashboard/src/hooks/dashboard-data.mjs +++ b/dashboard/src/hooks/dashboard-data.mjs @@ -4,11 +4,13 @@ export const dashboardProjectEvents = Object.freeze([ Object.freeze({ ovenId: "*", kind: "definition-changed", phase: "complete" }), Object.freeze({ ovenId: "checklist", kind: "item-burned", phase: "completed" }), Object.freeze({ ovenId: "checklist", kind: "lifecycle-changed", phase: "complete" }), + Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }), ]); export const dashboardProgressEvents = Object.freeze([ Object.freeze({ ovenId: "checklist", kind: "data-published", phase: "complete" }), Object.freeze({ ovenId: "checklist", kind: "item-burned", phase: "completed" }), Object.freeze({ ovenId: "checklist", kind: "lifecycle-changed", phase: "complete" }), + Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }), ]); function selectedQuery(selected) { @@ -57,7 +59,8 @@ export function dashboardProgressSnapshotConfig(enabled, selected) { enabled: enabled && Boolean(selected), repoKey: selected?.repoKey ?? null, ovenId: "checklist", - subjectId: selected?.id ?? null, + // Loop invalidations are keyed by an itemRef; progress is the owning Burnlist projection. + subjectId: null, query, makeUrl: () => `/api/progress?${query}`, receive: receiveProgress, @@ -67,3 +70,20 @@ export function dashboardProgressSnapshotConfig(enabled, selected) { deps: [enabled, query], }; } + +export function receiveLoopProjection(response, json) { + if (!response.ok) throw new Error(json?.error ?? "Could not load Loop projection."); + if (!json || typeof json !== "object" || !("loopRun" in json)) throw new Error("Loop projection data is invalid."); + return json.loopRun; +} + +export function dashboardLoopProjectionSnapshotConfig(enabled, selected) { + const query = selectedQuery(selected); + return { + transport: "snapshot", enabled: enabled && Boolean(selected), repoKey: selected?.repoKey ?? null, + ovenId: "checklist", subjectId: null, query: `loop/${query}`, + makeUrl: () => `/api/loop-projection?${query}`, receive: receiveLoopProjection, + fallbackError: "Could not load Loop projection.", initialData: null, + events: [Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" })], deps: [enabled, query], + }; +} diff --git a/dashboard/src/hooks/dashboard-data.test.mjs b/dashboard/src/hooks/dashboard-data.test.mjs index 93c8e955..3456b633 100644 --- a/dashboard/src/hooks/dashboard-data.test.mjs +++ b/dashboard/src/hooks/dashboard-data.test.mjs @@ -1,15 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createOvenSnapshotClient } from "../lib/oven-event-client.mjs"; -import { dashboardProgressSnapshotConfig, dashboardProjectsSnapshotConfig } from "./dashboard-data.mjs"; +import { dashboardLoopProjectionSnapshotConfig, dashboardProgressSnapshotConfig, dashboardProjectsSnapshotConfig } from "./dashboard-data.mjs"; const settle = () => new Promise((resolve) => setImmediate(resolve)); -function response(body, status = 200) { +function response(body, status = 200, etag = null) { return { ok: status >= 200 && status < 300, status, - headers: { get: () => null }, + headers: { get: (name) => name.toLowerCase() === "etag" ? etag : null }, async json() { return body; }, }; } @@ -135,3 +135,70 @@ test("landing projections share matching invalidations and the manual-write fall assert.equal(sources.length, 1); client.stop(); }); + +test("loop projection uses its dedicated snapshot URL and coalesces conditional event/reset refreshes", async () => { + const timers = fakeTimers(), sources = [], calls = [], states = []; + const selected = { repoKey: "aaaaaaaaaaaa", id: "260722-001" }; + const config = dashboardLoopProjectionSnapshotConfig(true, selected); + assert.equal(config.makeUrl(), "/api/loop-projection?repoKey=aaaaaaaaaaaa&id=260722-001"); + assert.deepEqual(config.events, [{ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }]); + const client = createOvenSnapshotClient({ + timers, focusTarget: null, + eventSourceFactory(url) { const source = new FakeEventSource(url); sources.push(source); return source; }, + async fetchImpl(url, options = {}) { + if (isEventBaseline(url)) return response({ cursor: "oev1-loop" }); + calls.push({ url, headers: options.headers ?? null }); + return calls.length === 1 ? response({ loopRun: { revision: "sha256:first" } }, 200, '"loop-v1"') : response(null, 304, '"loop-v1"'); + }, + }); + client.start(); + client.subscribe(descriptor(config), (state) => states.push(state)); + await settle(); + assert.deepEqual(calls, [{ url: config.makeUrl(), headers: null }]); + + const changed = { repoKey: selected.repoKey, ovenId: "checklist", subjectId: "item:260722-001#M7", kind: "loop-projection-changed", phase: "complete" }; + sources[0].publish(changed); + sources[0].publish(changed); + timers.flush(); + await settle(); + assert.equal(calls.length, 2, "two invalidations share one refetch"); + assert.deepEqual(calls[1].headers, { "If-None-Match": '"loop-v1"' }); + assert.equal(states.at(-1).outcome, "unchanged", "304 retains the canonical loop snapshot"); + + sources[0].reset({ repoKey: selected.repoKey, ovenId: "checklist", reason: "retention-gap" }); + timers.flush(); + await settle(); + assert.equal(calls.length, 3, "a matching retention gap resets the loop snapshot"); + assert.deepEqual(calls[2].headers, { "If-None-Match": '"loop-v1"' }); + client.stop(); +}); + +test("a corrupt dedicated Loop projection retains the last good snapshot", async () => { + const timers = fakeTimers(), sources = [], states = []; + const selected = { repoKey: "aaaaaaaaaaaa", id: "260722-001" }; + const config = dashboardLoopProjectionSnapshotConfig(true, selected); + let requests = 0; + const client = createOvenSnapshotClient({ + timers, focusTarget: null, + eventSourceFactory(url) { const source = new FakeEventSource(url); sources.push(source); return source; }, + async fetchImpl(url) { + if (isEventBaseline(url)) return response({ cursor: "oev1-corrupt" }); + requests += 1; + return requests === 1 + ? response({ loopRun: { revision: "sha256:verified" } }, 200, '"loop-v1"') + : response({ error: "Loop projection is unavailable; retaining the last verified projection." }, 409); + }, + }); + client.start(); + client.subscribe(descriptor(config), (state) => states.push(state)); + await settle(); + sources[0].publish({ repoKey: selected.repoKey, ovenId: "checklist", subjectId: "item:260722-001#M7", kind: "loop-projection-changed", phase: "complete" }); + timers.flush(); + await settle(); + const state = states.at(-1); + assert.equal(state.outcome, "rejected"); + assert.deepEqual(state.data, { revision: "sha256:verified" }); + assert.equal(state.error, "Loop projection is unavailable; retaining the last verified projection."); + assert.equal(state.stale, true); + client.stop(); +}); diff --git a/dashboard/src/hooks/useDashboardData.ts b/dashboard/src/hooks/useDashboardData.ts index 0b1e54fa..4e0c066c 100644 --- a/dashboard/src/hooks/useDashboardData.ts +++ b/dashboard/src/hooks/useDashboardData.ts @@ -1,6 +1,6 @@ import type { ProgressData, Project, SelectedBurnlist } from "@lib"; import { useOvenLiveData } from "@oven"; -import { dashboardProgressSnapshotConfig, dashboardProjectsSnapshotConfig } from "./dashboard-data.mjs"; +import { dashboardLoopProjectionSnapshotConfig, dashboardProgressSnapshotConfig, dashboardProjectsSnapshotConfig } from "./dashboard-data.mjs"; type DashboardData = { projects: Project[]; @@ -14,11 +14,19 @@ export function useDashboardData({ section, selected }: { section: string; selec const enabled = section === "burnlists"; const projectsState = useOvenLiveData(dashboardProjectsSnapshotConfig(enabled)); const progressState = useOvenLiveData(dashboardProgressSnapshotConfig(enabled, selected)); + const loopState = useOvenLiveData(dashboardLoopProjectionSnapshotConfig(enabled, selected)); + const loopDiagnostic = loopState.error.includes("retaining the last verified projection") ? "corrupt" + : loopState.stale || loopState.error ? "stale" : undefined; + const loopRun = loopState.data && loopDiagnostic ? { ...loopState.data, diagnostic: loopDiagnostic } : loopState.data; return { projects: projectsState.data ?? [], - progress: selected ? progressState.data : null, - error: progressState.error || projectsState.error, - loading: enabled && (projectsState.loading || (Boolean(selected) && progressState.loading)), - stale: projectsState.stale || (Boolean(selected) && progressState.stale), + progress: selected && progressState.data ? { + ...progressState.data, + loopRun, + ...(loopDiagnostic ? { loopProjectionDiagnostic: loopDiagnostic, loopProjectionMessage: loopState.error || undefined } : {}), + } : null, + error: loopState.error || progressState.error || projectsState.error, + loading: enabled && (projectsState.loading || (Boolean(selected) && (progressState.loading || loopState.loading))), + stale: projectsState.stale || (Boolean(selected) && (progressState.stale || loopState.stale)), }; } diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 0f6d5f03..d42ba877 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -4,6 +4,37 @@ export type ChecklistItem = { id: string; title: string; fields: Record; + edges: Array<{ from: string; on: string; to: string }>; + }; + transitions: Array<{ sequence: number; from: string; outcome: string; to: string }>; + diagnostic?: "stale" | "corrupt"; +}; export type ChecklistProgressData = { generatedAt: string; @@ -19,6 +50,9 @@ export type ChecklistProgressData = { active: ChecklistItem[]; completed: CompletedItem[]; history: HistoryPoint[]; + loopRun?: LoopRunProjection | null; + loopProjectionDiagnostic?: "corrupt" | "stale"; + loopProjectionMessage?: string; }; export type Burnlist = { diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 054d7478..2917b740 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { existsSync, @@ -36,6 +36,7 @@ import { starterOvenSource } from "../ovens/oven-starter.mjs"; import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; +import { readLatestRunForItem } from "../loops/run/read-projection.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { createOvenJsonSnapshotStore, OVEN_JSON_CACHE_MAX_BYTES } from "./oven-json-snapshot.mjs"; import { discoverBurnlistSummaries } from "./burnlist-discovery.mjs"; @@ -63,6 +64,7 @@ import { documentPayloadForPlan, lifecycleForPlan, localIsoTimestamp, + loopAssignmentForItem, parsePlan, twoDigit, validatePlan, @@ -1016,6 +1018,7 @@ function payloadForPlan(selection) { }) .filter((entry) => Number.isFinite(Date.parse(entry.time))); const history = [...ledgerHistory, current].sort((a, b) => Date.parse(a.time) - Date.parse(b.time)); + const burnlistId = burnlistIdForPlan(selection.planPath); return { generatedAt, burnlists: discoverBurnlists(), @@ -1026,7 +1029,7 @@ function payloadForPlan(selection) { return null; } })(), - burnlistId: burnlistIdForPlan(selection.planPath), + burnlistId, repo: plan.repo, repoRoot: plan.repoRoot, title: plan.title, @@ -1044,9 +1047,26 @@ function payloadForPlan(selection) { detail: completedDetails.get(entry.id)?.detail ?? "", })), history, + // The Run journal is deliberately not read here. Progress must remain + // useful when an independent Loop projection is corrupt or unavailable. + loopRun: null, }; } +function loopProjectionForPlan(selection) { + const plan = parsePlan(selection.planPath, maxPlanBytes); + const currentItem = plan.items[0]; + const assignment = currentItem ? loopAssignmentForItem(plan.markdown, currentItem.id) : null; + if (!currentItem || !assignment) return null; + return readLatestRunForItem({ + repoRoot: selection.repoRoot, + itemRef: `item:${burnlistIdForPlan(selection.planPath)}#${currentItem.id}`, + markdown: plan.markdown, + itemId: currentItem.id, + assignmentId: assignment["Assignment-Id"], + }); +} + function appendCompletionDigestIfMissing(plan) { if (/^##\s+Completion Digest\b/m.test(plan.markdown)) return false; const digest = completionDigestMarkdown(plan); @@ -1128,6 +1148,19 @@ function json(res, status, body) { res.end(serialized); } +function serveLoopProjection(req, res, loopRun) { + const body = { loopRun }; + const serialized = JSON.stringify(body); + const etag = `W/\"loop-${createHash("sha256").update(serialized).digest("hex")}\"`; + if (req.headers["if-none-match"] === etag) { + res.writeHead(304, { etag, "cache-control": "no-store" }); + res.end(); + return; + } + res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", etag, "content-length": Buffer.byteLength(serialized) }); + res.end(serialized); +} + function dashboardAssetPath(pathname) { if (pathname === "/favicon.svg") return resolve(dashboardDistDir, "favicon.svg"); const match = pathname.match(/^\/assets\/([A-Za-z0-9._-]+)$/u); @@ -1255,6 +1288,19 @@ const server = createServer(async (req, res) => { } return; } + if (url.pathname === "/api/loop-projection") { + if (method !== "GET") return json(res, 405, { error: "method not allowed" }); + const selected = selectedBurnlist(url); + if (!selected.burnlist) return json(res, 409, { error: selected.error }); + try { + // This representation is deliberately only the sanitized canonical projection. + serveLoopProjection(req, res, loopProjectionForPlan(selected.burnlist)); + } catch (error) { + const status = error?.code === "EAMBIGUOUS" || error?.code === "ECORRUPT" || error?.code === "ERUN_PROJECTION" || error?.code === "EAUTHORITY" ? 409 : 500; + json(res, status, { error: status === 409 ? "Loop projection is unavailable; retaining the last verified projection." : "Loop projection is unavailable." }); + } + return; + } if (url.pathname === "/api/events") { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); serveOvenEventFeed({ req, res, url, repos: ovenScopeRepos(), json, observer: ovenEventObserver }); diff --git a/src/server/dashboard-routes-fixtures.mjs b/src/server/dashboard-routes-fixtures.mjs index 5eb7c348..094af8b7 100644 --- a/src/server/dashboard-routes-fixtures.mjs +++ b/src/server/dashboard-routes-fixtures.mjs @@ -198,7 +198,7 @@ function httpRequest(baseUrl, path, { method, headers, body }) { const chunks = []; response.setEncoding("utf8"); response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => resolveResponse({ status: response.statusCode, body: chunks.join("") })); + response.on("end", () => resolveResponse({ status: response.statusCode, headers: response.headers, body: chunks.join("") })); }); req.once("error", reject); req.end(body); diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index cbc606d2..ba0ba069 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { legacyOvenRevision, @@ -9,6 +10,142 @@ import { } from "../ovens/oven-contract.mjs"; import test from "node:test"; import { detailFixture, httpGet, httpRequest, withServer } from "./dashboard-routes-fixtures.mjs"; +import { + createProductionRunAuthority, + fixtureItemRef, + fixtureRunId, + m4ProgressOutcomes, + runM4ProgressFixture, +} from "../loops/run/run-test-fixtures.mjs"; +import { createProductionRun } from "../loops/run/binder.mjs"; +import { runStore } from "../loops/run/run-store.mjs"; + +test("selected progress remains independent from the sanitized read-only Loop projection", { timeout: 20_000 }, async () => { + await withServer({ burnlists: [{ id: "260722-001", title: "Fixture M5" }], setup: async ({ fixtureRoot }) => { + const repo = `${fixtureRoot}/fixture-repo`; + createProductionRunAuthority(repo); + await runM4ProgressFixture({ + repoRoot: repo, + runId: fixtureRunId, + itemRef: fixtureItemRef, + outcomes: m4ProgressOutcomes, + }); + } }, async ({ baseUrl, planPath }) => { + const url = `/api/progress?plan=${encodeURIComponent(planPath)}`; + const first = await httpGet(baseUrl, url), second = await httpGet(baseUrl, url); + assert.equal(first.status, 200); + assert.equal(JSON.parse(first.body).loopRun, null); + assert.equal(JSON.parse(second.body).loopRun, null); + const projection = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(projection.status, 200); + const left = JSON.parse(projection.body).loopRun; + assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]); + assert.equal(left.loopId, "review"); + assert.equal(left.loopRevision, null, "generic fixture has no sealed Run authority"); + assert.equal(Number.isSafeInteger(left.createdAt), true); + assert.equal(Number.isSafeInteger(left.updatedAt), true); + assert.ok(left.updatedAt >= left.createdAt); + assert.match(left.revision, /^sha256:[a-f0-9]{64}$/u); + assert.equal(left.budget.limits.maxRounds, 3); + assert.equal(left.state, "converged"); + assert.equal(left.currentNode, "completed"); + assert.deepEqual(left.latestResult, { kind: "approve", summary: "approve" }); + for (const result of [left.latestMaker, left.latestCheck, left.latestReviewer]) { + assert.equal(typeof result?.summary, "string"); + assert.equal(typeof result?.at, "number"); + assert.ok(result?.candidateId === null || /^cm1-sha256:/u.test(result?.candidateId)); + } + assert.deepEqual(left.transitions.map(({ from, outcome, to }) => ({ from, outcome, to })), [ + { from: "prepared", outcome: "control", to: "running" }, + { from: "implement", outcome: "complete", to: "verify" }, + { from: "verify", outcome: "pass", to: "review" }, + { from: "review", outcome: "reject", to: "implement" }, + { from: "implement", outcome: "complete", to: "verify" }, + { from: "verify", outcome: "pass", to: "review" }, + { from: "review", outcome: "approve", to: "converged" }, + { from: "converged", outcome: "pass", to: "completed" }, + ]); + const serialized = JSON.stringify(left); + for (const forbidden of ["invocationId", "lease", "prompt", "route", "authority"]) assert.doesNotMatch(serialized, new RegExp(forbidden, "u")); + assert.equal((await httpRequest(baseUrl, url, { method: "POST" })).status, 405); + const etag = projection.headers.etag; + assert.match(etag, /^W\/"loop-[a-f0-9]{64}"$/u); + const unchanged = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET", headers: { "if-none-match": etag } }); + assert.equal(unchanged.status, 304); + }); +}); + +test("unassigned selected progress stays unchanged and Run discovery does not create state", { timeout: 20_000 }, async () => { + await withServer({ withBurnlist: true }, async ({ baseUrl, planPath, repoRoot }) => { + const response = await httpGet(baseUrl, `/api/progress?plan=${encodeURIComponent(planPath)}`); + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).loopRun, null); + assert.equal(existsSync(`${repoRoot}/.local/burnlist/loop/m2/runs`), false); + }); +}); + +test("loop projection is a bounded byte-stable conditional read", { timeout: 20_000 }, async () => { + await withServer({ burnlists: [{ id: "260722-001", title: "Loop route" }], setup: async ({ fixtureRoot }) => { + const repo = `${fixtureRoot}/fixture-repo`; + createProductionRunAuthority(repo); + await runM4ProgressFixture({ repoRoot: repo, runId: fixtureRunId, itemRef: fixtureItemRef, outcomes: m4ProgressOutcomes }); + } }, async ({ baseUrl, planPath }) => { + const path = `/api/loop-projection?plan=${encodeURIComponent(planPath)}`; + const first = await httpRequest(baseUrl, path, { method: "GET" }); + const second = await httpRequest(baseUrl, path, { method: "GET" }); + assert.equal(first.status, 200); + assert.equal(first.body, second.body, "canonical projection serialization is byte-stable"); + assert.equal(first.headers.etag, second.headers.etag); + assert.equal(first.headers.etag, `W/"loop-${createHash("sha256").update(first.body).digest("hex")}"`); + assert.equal(Number(first.headers["content-length"]), Buffer.byteLength(first.body)); + assert.ok(Buffer.byteLength(first.body) <= 65_536, "sanitized loop response remains bounded"); + assert.deepEqual(Object.keys(JSON.parse(first.body)), ["loopRun"]); + const unchanged = await httpRequest(baseUrl, path, { method: "GET", headers: { "if-none-match": first.headers.etag } }); + assert.equal(unchanged.status, 304); + assert.equal(unchanged.body, ""); + assert.equal((await httpRequest(baseUrl, path, { method: "POST" })).status, 405); + }); +}); + +test("loop projection distinguishes missing state from corrupt run storage", { timeout: 40_000 }, async () => { + const missing = async ({ fixtureRoot }) => { createProductionRunAuthority(`${fixtureRoot}/fixture-repo`); }; + const corrupt = async ({ fixtureRoot }) => { + const repo = `${fixtureRoot}/fixture-repo`; + createProductionRunAuthority(repo); + mkdirSync(`${repo}/.local/burnlist/loop/m2/runs/not-a-run`, { recursive: true }); + }; + for (const [setup, expectedStatus] of [[missing, 200], [corrupt, 409]]) { + await withServer({ burnlists: [{ id: "260722-001", title: "Loop route" }], setup }, async ({ baseUrl, planPath }) => { + const progress = await httpRequest(baseUrl, `/api/progress?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(progress.status, 200, "progress remains usable when Loop storage is corrupt"); + const response = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(response.status, expectedStatus); + if (expectedStatus === 200) assert.equal(JSON.parse(response.body).loopRun, null); + else assert.equal(JSON.parse(response.body).error, "Loop projection is unavailable; retaining the last verified projection."); + }); + } +}); + +test("sealed production authority corruption returns the dedicated projection conflict while progress stays healthy", { timeout: 60_000 }, async () => { + for (const mutation of ["missing", "malformed"]) { + await withServer({ burnlists: [{ id: "260722-001", title: `Loop authority ${mutation}` }], setup: async ({ fixtureRoot }) => { + const repo = `${fixtureRoot}/fixture-repo`; + createProductionRunAuthority(repo); + const store = runStore(repo); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const authorityPath = store.paths.authorityPath(fixtureRunId); + if (mutation === "missing") rmSync(authorityPath); + else writeFileSync(authorityPath, "{not-json\n"); + } }, async ({ baseUrl, planPath }) => { + const progress = await httpRequest(baseUrl, `/api/progress?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(progress.status, 200, `progress remains usable with ${mutation} sealed authority`); + assert.equal(JSON.parse(progress.body).loopRun, null); + const projection = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); + assert.equal(projection.status, 409, `${mutation} sealed authority is projection corruption`); + assert.deepEqual(JSON.parse(projection.body), { error: "Loop projection is unavailable; retaining the last verified projection." }); + }); + } +}); test("Burn runs read legacy v3/v4 revisions and write/read v5 revisions", { timeout: 20_000 }, async () => { const legacyInstructions = "# Legacy Oven\n\nFollow the checklist.\n"; const legacyDetail = detailFixture(); From 61c68027fbc49122837b9ea206657938e64ae008 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:20 +0200 Subject: [PATCH 04/23] feat: expose loop commands from the main CLI --- bin/burnlist.mjs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 37aa9bf9..95105171 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -20,6 +20,9 @@ const knownSubcommands = new Set([ "start", "close", "burn", + "loop", + "agent", + "route", "register", "unregister", "roots", @@ -83,7 +86,7 @@ if (args[0] && !args[0].startsWith("--") && !["-h", "-v"].includes(args[0]) && ! process.exit(2); } -if (!["oven", "hooks"].includes(args[0]) && (args.includes("--help") || args.includes("-h"))) { +if (!["oven", "hooks", "loop", "agent", "route"].includes(args[0]) && (args.includes("--help") || args.includes("-h"))) { console.log(`Burnlist Usage: @@ -105,6 +108,20 @@ Usage: burnlist start [--repo ] burnlist close [--repo ] burnlist burn [--check] [--repo ] + burnlist loop assign [--repo ] + burnlist loop unassign [--repo ] + burnlist loop view [--repo ] + burnlist loop create [--repo ] + burnlist loop list [--repo ] + burnlist loop run|resume [--repo ] + burnlist loop status|inspect [--repo ] + burnlist loop pause|stop [--repo ] (idle Run only) + burnlist loop reconcile --recovery-proof [--repo ] + burnlist loop complete [--repo ] + burnlist loop capability ... + burnlist loop setup status [--repo ] + burnlist agent ... + burnlist route set --profile [--repo ] burnlist register [path] burnlist unregister [path] burnlist roots [--prune] @@ -144,6 +161,15 @@ if (args[0] === "oven") { await import("../src/cli/hooks-cli.mjs"); } else if (["new", "show", "ready", "start", "close", "burn"].includes(args[0])) { await import("../src/cli/lifecycle-cli.mjs"); +} else if (args[0] === "loop") { + const { runLoopCliEntry } = await import("../src/cli/loop-cli.mjs"); + await runLoopCliEntry(args.slice(1)); +} else if (args[0] === "agent") { + const { runAgentCliEntry } = await import("../src/cli/loop-config-cli.mjs"); + await runAgentCliEntry(args.slice(1)); +} else if (args[0] === "route") { + const { runRouteCliEntry } = await import("../src/cli/loop-config-cli.mjs"); + await runRouteCliEntry(args.slice(1)); } else if (["register", "unregister", "roots", "init"].includes(args[0])) { await import("../src/cli/registry-cli.mjs"); } else { From 9f2b8dd12071217fecce54722dbbae4037de948f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:20 +0200 Subject: [PATCH 05/23] test: verify the installed loop product --- package.json | 8 +- scripts/package-paths.json | 204 ++++++++++++++++++++++++++++ scripts/smoke-global-install.mjs | 113 +++++++++++++-- scripts/verify-package.mjs | 93 ++++++++++++- scripts/verify-source-scan.mjs | 23 ++++ scripts/verify-source-scan.test.mjs | 26 ++++ scripts/verify-test-files.mjs | 33 ++++- scripts/verify.mjs | 19 +-- 8 files changed, 483 insertions(+), 36 deletions(-) create mode 100644 scripts/package-paths.json create mode 100644 scripts/verify-source-scan.mjs create mode 100644 scripts/verify-source-scan.test.mjs diff --git a/package.json b/package.json index 66cdb8b1..687f10b1 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,19 @@ "files": [ "bin/", "src/", + "loops/", "skills/", "ovens/", "dashboard/dist/", "scripts/register-skills.mjs", "scripts/unregister-skills.mjs", "!**/*.test.mjs", - "!**/*.test.js" + "!**/*.test.js", + "!**/__fixtures__/", + "!src/loops/minimal-review-e2e-fixtures.mjs", + "!src/loops/run/m2-test-fixtures.mjs", + "!src/loops/run/run-test-fixtures.mjs", + "!src/server/dashboard-routes-fixtures.mjs" ], "scripts": { "build:dashboard": "vite build --config dashboard/vite.config.mjs", diff --git a/scripts/package-paths.json b/scripts/package-paths.json new file mode 100644 index 00000000..2012abda --- /dev/null +++ b/scripts/package-paths.json @@ -0,0 +1,204 @@ +[ + "LICENSE", + "README.md", + "bin/burnlist.mjs", + "dashboard/dist/assets/index-BHMojAeL.js", + "dashboard/dist/assets/index-qhZB46pA.css", + "dashboard/dist/favicon.svg", + "dashboard/dist/index.html", + "loops/review/instructions.md", + "loops/review/review.loop", + "ovens/catalog.json", + "ovens/checklist/checklist.oven", + "ovens/checklist/instructions.md", + "ovens/differential-testing/differential-testing.oven", + "ovens/differential-testing/engine/adapter-sdk.mjs", + "ovens/differential-testing/engine/contract.mjs", + "ovens/differential-testing/engine/data-contract.mjs", + "ovens/differential-testing/engine/data.schema.json", + "ovens/differential-testing/engine/handler.mjs", + "ovens/differential-testing/engine/query-projection-cache.mjs", + "ovens/differential-testing/engine/transport.mjs", + "ovens/differential-testing/engine/worker-runtime.mjs", + "ovens/differential-testing/example/adapter.mjs", + "ovens/differential-testing/example/candidate.json", + "ovens/differential-testing/example/reference.json", + "ovens/differential-testing/instructions.md", + "ovens/model-lab/engine/model-lab-contract.mjs", + "ovens/model-lab/engine/model-lab-handler.mjs", + "ovens/model-lab/instructions.md", + "ovens/model-lab/model-lab.oven", + "ovens/model-lab/renderer/model-lab.css", + "ovens/performance-tracing/contract.mjs", + "ovens/performance-tracing/handler.mjs", + "ovens/performance-tracing/instructions.md", + "ovens/performance-tracing/performance-tracing.oven", + "ovens/streaming-diff/engine/streaming-diff-capture-git.mjs", + "ovens/streaming-diff/engine/streaming-diff-capture.mjs", + "ovens/streaming-diff/engine/streaming-diff-claude-hook.mjs", + "ovens/streaming-diff/engine/streaming-diff-codex-hook.mjs", + "ovens/streaming-diff/engine/streaming-diff-data-contract.mjs", + "ovens/streaming-diff/engine/streaming-diff-durable-write.mjs", + "ovens/streaming-diff/engine/streaming-diff-ensure-feed.mjs", + "ovens/streaming-diff/engine/streaming-diff-feed-capture.mjs", + "ovens/streaming-diff/engine/streaming-diff-feed.mjs", + "ovens/streaming-diff/engine/streaming-diff-handler.mjs", + "ovens/streaming-diff/engine/streaming-diff-hook-adapters.mjs", + "ovens/streaming-diff/engine/streaming-diff-hook-contract.mjs", + "ovens/streaming-diff/engine/streaming-diff-journal.mjs", + "ovens/streaming-diff/engine/streaming-diff-linediff.mjs", + "ovens/streaming-diff/engine/streaming-diff-snapshot-store.mjs", + "ovens/streaming-diff/instructions.md", + "ovens/streaming-diff/streaming-diff.oven", + "ovens/visual-parity/contract.mjs", + "ovens/visual-parity/handler.mjs", + "ovens/visual-parity/instructions.md", + "ovens/visual-parity/visual-parity.oven", + "package.json", + "scripts/register-skills.mjs", + "scripts/unregister-skills.mjs", + "skills/burnlist/SKILL.md", + "skills/burnlist/agents/openai.yaml", + "skills/burnlist/references/burnlist-creation.md", + "skills/burnlist/references/burnlist-dashboard.md", + "skills/burnlist/references/burnlist-protocol.md", + "skills/burnlist/references/burnlist-splitting-lanes.md", + "skills/burnlist/references/burnlist-visible-output.md", + "skills/burnlist/references/creating-ovens.md", + "skills/burnlist/references/designing-ovens.md", + "skills/burnlist/references/differential-testing-adapter-sdk.md", + "skills/burnlist/references/differential-testing-data.md", + "skills/burnlist/references/getting-started.md", + "skills/burnlist/references/installation.md", + "skills/burnlist/references/loop-capability-example.json", + "skills/burnlist/references/oven-authoring.md", + "skills/burnlist/references/oven-contract.md", + "skills/burnlist/references/oven-event-coordination.md", + "src/cli/atomic-quarantine.mjs", + "src/cli/git-ignore.mjs", + "src/cli/hooks-cli.mjs", + "src/cli/hooks-config.mjs", + "src/cli/lifecycle-cli.mjs", + "src/cli/lifecycle-moves.mjs", + "src/cli/local-exclude.mjs", + "src/cli/loop-cli.mjs", + "src/cli/loop-config-cli.mjs", + "src/cli/oven-cli-render.mjs", + "src/cli/oven-cli.mjs", + "src/cli/oven-set.mjs", + "src/cli/oven-storage.mjs", + "src/cli/oven-use.mjs", + "src/cli/registry-cli.mjs", + "src/cli/skills-exclude.mjs", + "src/cli/skills-install-cli.mjs", + "src/cli/skills-install-git.mjs", + "src/cli/skills-install-lock.mjs", + "src/cli/skills-install-transaction.mjs", + "src/cli/skills-register.mjs", + "src/cli/streaming-diff-cli.mjs", + "src/cli/umbrella.mjs", + "src/events/oven-canonical-mutations.mjs", + "src/events/oven-data-events.mjs", + "src/events/oven-event-contract.mjs", + "src/events/oven-event-deliveries.mjs", + "src/events/oven-event-feed.mjs", + "src/events/oven-event-observer.mjs", + "src/events/oven-event-store.mjs", + "src/events/oven-event-stream-storage.mjs", + "src/events/oven-events.mjs", + "src/loops/adapters/codex-cli.mjs", + "src/loops/adapters/normalized-invocation.mjs", + "src/loops/agents/profile.mjs", + "src/loops/assignment/assignment.mjs", + "src/loops/assignment/hazards.mjs", + "src/loops/assignment/item-metadata.mjs", + "src/loops/assignment/resolver.mjs", + "src/loops/assignment/selectors.mjs", + "src/loops/assignment/store-worker.mjs", + "src/loops/assignment/store.mjs", + "src/loops/capabilities/contract.mjs", + "src/loops/capabilities/runner.mjs", + "src/loops/capabilities/snapshot.mjs", + "src/loops/capabilities/trust.mjs", + "src/loops/completion/completion.mjs", + "src/loops/config/profiles.mjs", + "src/loops/config/setup.mjs", + "src/loops/config/store.mjs", + "src/loops/contracts/agent-result.mjs", + "src/loops/contracts/check-result.mjs", + "src/loops/contracts/contract.mjs", + "src/loops/contracts/finding.mjs", + "src/loops/dsl/canonical.mjs", + "src/loops/dsl/compile.mjs", + "src/loops/dsl/diagnostics.mjs", + "src/loops/dsl/frozen.mjs", + "src/loops/dsl/grammar.mjs", + "src/loops/dsl/hash.mjs", + "src/loops/dsl/instructions.mjs", + "src/loops/dsl/ir-validate.mjs", + "src/loops/dsl/loop-xml.mjs", + "src/loops/dsl/package-read.mjs", + "src/loops/events/projection-events.mjs", + "src/loops/run/binder.mjs", + "src/loops/run/candidate.mjs", + "src/loops/run/budgets.mjs", + "src/loops/run/controller.mjs", + "src/loops/run/current-authority.mjs", + "src/loops/run/launch-commit.mjs", + "src/loops/run/lifecycle.mjs", + "src/loops/run/operation.mjs", + "src/loops/run/read-projection.mjs", + "src/loops/run/run-admission.mjs", + "src/loops/run/run-artifacts.mjs", + "src/loops/run/run-claim.mjs", + "src/loops/run/run-clock.mjs", + "src/loops/run/run-codec.mjs", + "src/loops/run/run-created.mjs", + "src/loops/run/run-fold.mjs", + "src/loops/run/run-journal.mjs", + "src/loops/run/run-record.mjs", + "src/loops/run/run-ref.mjs", + "src/loops/run/run-result.mjs", + "src/loops/run/run-store.mjs", + "src/loops/run/runner.mjs", + "src/loops/run/state-machine.mjs", + "src/loops/view/render.mjs", + "src/ovens/built-in-handlers.mjs", + "src/ovens/dsl/oven-compile.mjs", + "src/ovens/dsl/oven-diagnostics.mjs", + "src/ovens/dsl/oven-grammar.mjs", + "src/ovens/dsl/oven-ir.mjs", + "src/ovens/dsl/oven-validate.mjs", + "src/ovens/dsl/xml-scan.mjs", + "src/ovens/handlers/checklist.mjs", + "src/ovens/handlers/generic-json-handler.mjs", + "src/ovens/official-oven-catalog.mjs", + "src/ovens/oven-contract.mjs", + "src/ovens/oven-data-validate.mjs", + "src/ovens/oven-registry.mjs", + "src/ovens/oven-runtime-compatibility.mjs", + "src/ovens/oven-starter.mjs", + "src/server/burnlist-dashboard-server.mjs", + "src/server/burnlist-discovery.mjs", + "src/server/dashboard-entry-isolation.mjs", + "src/server/dir-lock.mjs", + "src/server/discovery.mjs", + "src/server/fs-bounded-read.mjs", + "src/server/fs-safe.mjs", + "src/server/official-oven-discovery.mjs", + "src/server/oven-bindings.mjs", + "src/server/oven-data-store.mjs", + "src/server/oven-json-handler.mjs", + "src/server/oven-json-snapshot.mjs", + "src/server/oven-package-lock.mjs", + "src/server/oven-projection-coordinator.mjs", + "src/server/oven-response-stream.mjs", + "src/server/oven-storage.mjs", + "src/server/oven-vendor.mjs", + "src/server/plan-model.mjs", + "src/server/projects.mjs", + "src/server/registry.mjs", + "src/server/repo-map-worker.mjs", + "src/server/repo-map.mjs", + "src/server/repo-state.mjs" +] diff --git a/scripts/smoke-global-install.mjs b/scripts/smoke-global-install.mjs index 431dca82..92be1a72 100755 --- a/scripts/smoke-global-install.mjs +++ b/scripts/smoke-global-install.mjs @@ -1,12 +1,16 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; import { + existsSync, lstatSync, mkdirSync, mkdtempSync, + readFileSync, readlinkSync, realpathSync, rmSync, + chmodSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -29,12 +33,23 @@ const env = { USERPROFILE: home, npm_config_cache: npmCache, }; +const testNode = resolve(process.env.BURNLIST_TEST_NODE || process.execPath); +const npmCli = process.env.BURNLIST_TEST_NODE + ? resolve(dirname(dirname(testNode)), "lib", "node_modules", "npm", "bin", "npm-cli.js") + : process.env.npm_execpath; + +function assertSelectedNode() { + const version = spawnSync(testNode, ["--version"], { encoding: "utf8", shell: false }); + if (version.status !== 0) throw new Error("BURNLIST_TEST_NODE is not executable"); + if (process.env.BURNLIST_TEST_NODE && !/^v18\./u.test(version.stdout.trim())) throw new Error("BURNLIST_TEST_NODE must select Node 18 for the compatibility smoke"); + if (!npmCli || !existsSync(npmCli)) throw new Error("selected Node runtime does not provide npm-cli.js"); +} function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd || repoRoot, encoding: options.capture ? "utf8" : undefined, - env, + env: options.env ?? env, maxBuffer: 8 * 1024 * 1024, shell: false, stdio: options.capture ? "pipe" : "inherit", @@ -46,6 +61,12 @@ function run(command, args, options = {}) { return options.capture ? result.stdout.trim() : ""; } +function invokeCli(cli, args, options = {}) { + return run(testNode, [cli, ...args], options); +} + +function runNpm(args, options = {}) { return run(testNode, [npmCli, ...args], options); } + function assertManagedLink(agentDirectory, name, packageRoot) { const target = join(home, agentDirectory, "skills", name); const stat = lstatSync(target); @@ -55,12 +76,77 @@ function assertManagedLink(agentDirectory, name, packageRoot) { if (actual !== expected) throw new Error(`${target} points to ${actual}, expected ${expected}`); } +function command(cli, repo, args, options = {}) { + return invokeCli(cli, [...args, "--repo", repo], { ...options, cwd: repo }); +} + +function writeLoopFixture(repo) { + mkdirSync(join(repo, ".burnlist"), { recursive: true }); + mkdirSync(join(repo, "src"), { recursive: true }); + mkdirSync(join(repo, "notes", "burnlists", "inprogress", "260722-001"), { recursive: true }); + writeFileSync(join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"), "# Smoke Loop\n\n## Active Checklist\n- [ ] L1 | Packed Loop proof\n\n## Completed\n"); + const binary = join(repo, "fixtures", "fake-codex"); + mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(binary, `#!${testNode} +const fs=require("node:fs"),args=process.argv.slice(2),prompt=args.at(-1),lines=Object.fromEntries(prompt.split("\\n").filter((line)=>line.includes("=")).map((line)=>line.split(/=(.*)/s).slice(0,2))); +const counter=process.env.BURNLIST_FAKE_COUNTER,index=counter?Number(fs.readFileSync(counter,"utf8")):0,outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete,approve").split(",")[index]||"approve"; +if(counter)fs.writeFileSync(counter,String(index+1)); +const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome}; +process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"smoke-"+process.pid,model:args[args.indexOf("-m")+1]})+"\\n"); +process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:JSON.stringify(final)}})+"\\n"); +process.stdout.write(JSON.stringify({type:"turn.completed",usage:{input_tokens:1,output_tokens:1,cached_input_tokens:0}})+"\\n"); +`); + chmodSync(binary, 0o700); + const capability = { id: "repo-verify", argv: [testNode, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; + writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [capability] })}\n`); + writeFileSync(join(repo, "grants.json"), `${JSON.stringify({ argv: capability.argv, cwd: capability.cwd, environment: capability.environment, network: capability.network, filesystem: capability.filesystem, output: capability.output, maxMilliseconds: capability.maxMilliseconds })}\n`); + return { binary, itemRef: "item:260722-001#L1" }; +} + +function assertLoopFlow(cli) { + const repoPath = join(tmpRoot, "loop-repo"); + mkdirSync(repoPath, { recursive: true }); + run("git", ["init", "--quiet", repoPath]); + const repo = realpathSync(repoPath); + const { binary, itemRef } = writeLoopFixture(repo); + const profile = (slug, authority) => command(cli, repo, ["agent", "profile", "add", slug, "--adapter", "builtin:codex-cli", "--binary", binary, "--model", "gpt-5.6-terra", "--effort", "medium", "--authority", authority]); + profile("maker", "write"); profile("reviewer", "read"); + command(cli, repo, ["route", "set", "implementation.standard", "--profile", "maker"]); + command(cli, repo, ["route", "set", "review.strong", "--profile", "reviewer"]); + const capability = JSON.parse(command(cli, repo, ["loop", "capability", "inspect", "repo-verify"], { capture: true })); + command(cli, repo, ["loop", "capability", "trust", "repo-verify", "--revision", capability.revision, "--grants", join(repo, "grants.json")]); + const setup = command(cli, repo, ["loop", "setup", "status"], { capture: true }); + if (setup !== "Loop setup: ready") throw new Error(`packed Loop setup did not become ready: ${setup}`); + command(cli, repo, ["loop", "assign", itemRef, "loop:builtin:review"]); + const view = command(cli, repo, ["loop", "view", itemRef], { capture: true }); + if (!view.includes("LOOP: loop:builtin:review")) throw new Error("packed CLI did not render the assigned Loop"); + const runId = JSON.parse(command(cli, repo, ["loop", "create", itemRef], { capture: true })).runId; + for (const operation of ["status", "inspect"]) JSON.parse(command(cli, repo, ["loop", operation, runId], { capture: true })); + const counter = join(repo, "counter"); writeFileSync(counter, "0"); + const result = JSON.parse(command(cli, repo, ["loop", "run", runId], { capture: true, env: { ...env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" } })); + if (result.state !== "converged") throw new Error(`packed Loop did not converge: ${result.state}`); + const first = JSON.parse(command(cli, repo, ["loop", "complete", runId], { capture: true })); + const second = JSON.parse(command(cli, repo, ["loop", "complete", runId], { capture: true })); + if (first.alreadyApplied || !second.alreadyApplied) throw new Error("packed Loop completion was not idempotent"); + const plan = readFileSync(join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"), "utf8"); + if (/^- \[ \] L1 \|/mu.test(plan) || (plan.match(/^- L1 \| /gmu) ?? []).length !== 1) throw new Error("packed Loop completion did not atomically burn the assigned item"); + const runDirectory = join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(runId).toString("hex")); + if (!lstatSync(join(runDirectory, "completion-receipt.json")).isFile()) throw new Error("packed Loop completion did not retain its receipt"); + try { lstatSync(join(runDirectory, "completion-intent.json")); throw new Error("packed Loop completion left an intent behind"); } + catch (error) { if (error.code !== "ENOENT") throw error; } + const skillsBeforeHooks = realpathSync(join(home, ".agents", "skills", "burnlist")); + invokeCli(cli, ["hooks", "install", "--agent", "codex"], { cwd: repo }); + if (realpathSync(join(home, ".agents", "skills", "burnlist")) !== skillsBeforeHooks) throw new Error("hooks installation modified skill registration"); + invokeCli(cli, ["hooks", "uninstall", "--agent", "codex"], { cwd: repo }); +} + let exitCode = 0; try { + assertSelectedNode(); mkdirSync(home, { recursive: true }); mkdirSync(prefix, { recursive: true }); mkdirSync(packRoot, { recursive: true }); - const packJson = run("npm", [ + const packJson = runNpm([ "pack", "--ignore-scripts", "--json", @@ -70,25 +156,24 @@ try { const [packReport] = JSON.parse(packJson); const tarball = resolve(packRoot, packReport.filename); - run("npm", ["install", "--global", "--prefix", prefix, tarball]); - const globalRoot = run("npm", ["root", "--global", "--prefix", prefix], { capture: true }); + runNpm(["install", "--global", "--prefix", prefix, tarball]); + const globalRoot = runNpm(["root", "--global", "--prefix", prefix], { capture: true }); const packageRoot = resolve(globalRoot, "burnlist"); assertManagedLink(".claude", "burnlist", packageRoot); assertManagedLink(".agents", "burnlist", packageRoot); - const cli = process.platform === "win32" - ? join(prefix, "burnlist.cmd") - : join(prefix, "bin", "burnlist"); - const version = run(cli, ["--version"], { capture: true }); + const cli = join(packageRoot, "bin", "burnlist.mjs"); + const version = invokeCli(cli, ["--version"], { capture: true }); if (version !== packReport.version) { throw new Error(`installed CLI reported ${version}, expected ${packReport.version}`); } - run(cli, ["--stamp"], { capture: true }); - run(cli, ["oven", "list"], { capture: true }); - const sdkPath = run(cli, ["differential-testing", "sdk"], { capture: true }); + assertLoopFlow(cli); + invokeCli(cli, ["--stamp"], { capture: true }); + invokeCli(cli, ["oven", "list"], { capture: true }); + const sdkPath = invokeCli(cli, ["differential-testing", "sdk"], { capture: true }); const expectedSdkPath = resolve(packageRoot, "ovens", "differential-testing", "engine", "adapter-sdk.mjs"); if (realpathSync(sdkPath) !== realpathSync(expectedSdkPath)) throw new Error(`installed CLI reported unexpected SDK path: ${sdkPath}`); - run(process.execPath, ["--input-type=module", "--eval", ` + run(testNode, ["--input-type=module", "--eval", ` const sdk = await import(${JSON.stringify(pathToFileURL(sdkPath).href)}); const expected = [ "DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION", @@ -100,13 +185,13 @@ try { if (sdk.DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION !== 4 || JSON.stringify(Object.keys(sdk).sort()) !== JSON.stringify(expected.sort())) process.exit(1); `]); - run(process.execPath, ["--input-type=module", "--eval", ` + run(testNode, ["--input-type=module", "--eval", ` const events = await import("burnlist/oven-events"); const expected = ["normalizeOvenEvent", "publishOvenEvent", "readOvenEvents"]; if (!expected.every((name) => typeof events[name] === "function")) process.exit(1); `], { cwd: packageRoot }); - run(cli, ["uninstall", "--global", "--purge"]); + invokeCli(cli, ["uninstall", "--global", "--purge"]); for (const agentDirectory of [".claude", ".agents"]) { for (const name of ["burnlist"]) { try { diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 1cb1f975..59c89655 100755 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -25,6 +27,30 @@ try { } const files = new Map((report.files || []).map((entry) => [entry.path, entry])); +const packedPaths = [...files.keys()]; +const expectedPaths = JSON.parse(readFileSync(resolve(repoRoot, "scripts", "package-paths.json"), "utf8")); +if (!Array.isArray(expectedPaths) || JSON.stringify([...packedPaths].sort()) !== JSON.stringify([...expectedPaths].sort())) { + console.error("npm package paths differ from the committed package-paths.json manifest."); + process.exit(1); +} +const forbiddenPayloadBytes = [ + ["BEGIN", "PRIVATE", "KEY"].join(" "), + ["BEGIN", "OPENSSH", "PRIVATE", "KEY"].join(" "), + ["BURNLIST", "FAKE", ""].join("_"), +]; +const forbiddenPayloadText = [ + /(?:^|[^\w])\/(?:Users|home)\/[^/\s]+(?:\/|$)/u, + /\b(?:SECRET|TOKEN|API_KEY)\s*=/u, + /\b(?:prototype-only|prototype command|prototype CLI)\b/iu, +]; + +function sourceFiles(directory, root = repoRoot) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path, root); + return entry.isFile() ? [relative(root, path).replace(/\\/gu, "/")] : []; + }); +} const required = [ "LICENSE", "README.md", @@ -43,6 +69,9 @@ const required = [ "src/ovens/oven-contract.mjs", "src/ovens/official-oven-catalog.mjs", "src/server/burnlist-dashboard-server.mjs", + "loops/review/review.loop", + "loops/review/instructions.md", + "skills/burnlist/references/loop-capability-example.json", "skills/burnlist/SKILL.md", "skills/burnlist/references/burnlist-creation.md", "skills/burnlist/references/oven-event-coordination.md", @@ -83,6 +112,19 @@ for (const path of required) { } } +// The Loop is not an optional documentation add-on: every non-test runtime +// module and installed Loop source must be present in the tarball. +const runtimeSources = [ + ...sourceFiles(resolve(repoRoot, "src/loops")), + ...sourceFiles(resolve(repoRoot, "loops")), +].filter((path) => !path.includes("/__fixtures__/") && !/\.test\.m?js$/u.test(path) && !/(?:minimal-review-e2e-fixtures|m2-test-fixtures|run-test-fixtures)\.mjs$/u.test(path)); +for (const path of runtimeSources) { + if (!files.has(path)) { + console.error(`npm package is missing Loop runtime asset: ${path}`); + process.exit(1); + } +} + for (const extension of [".css", ".js"]) { if (![...files.keys()].some((path) => path.startsWith("dashboard/dist/assets/") && path.endsWith(extension))) { console.error(`npm package is missing the built dashboard ${extension} asset.`); @@ -107,21 +149,68 @@ const forbidden = [ /^skills\/burnlist\/references\/compare-data\.md$/u, /^skills\/burnlist\/scripts\/compare-data-contract(?:\.test)?\.mjs$/u, /^skills\/burnlist\/ovens\/target(?:\/|$)/u, + /^src\/loops\/minimal-review-e2e-fixtures\.mjs$/u, + /^src\/loops\/adapters\/docker-isolation\.mjs$/u, + /^src\/loops\/config\/(?:controllers|probes)\.mjs$/u, + /^src\/loops\/run\/(?:m2|run)-test-fixtures\.mjs$/u, + /^src\/server\/dashboard-routes-fixtures\.mjs$/u, + /(?:^|\/)__fixtures__(?:\/|$)/u, /\.test\.(?:mjs|js)$/u, /\.zip$/u, ]; -for (const path of files.keys()) { +for (const path of packedPaths) { if (forbidden.some((pattern) => pattern.test(path))) { console.error(`npm package contains forbidden file: ${path}`); process.exit(1); } } +for (const path of packedPaths.filter((entry) => !entry.startsWith("dashboard/dist/assets/"))) { + const text = readFileSync(resolve(repoRoot, path), "utf8"); + if (/\/Users\//u.test(text)) { + console.error(`npm package contains a personal path: ${path}`); + process.exit(1); + } +} + const bin = files.get("bin/burnlist.mjs"); if ((bin.mode & 0o111) === 0) { console.error("npm package CLI is not executable."); process.exit(1); } +if (report.entryCount > 220 || report.unpackedSize > 2_500_000) { + console.error(`npm package exceeds its bounded payload budget: ${report.entryCount} files, ${report.unpackedSize} bytes.`); + process.exit(1); +} + +// npm's dry-run manifest is useful for policy, but inspect the extracted bytes +// too: this catches leaks in generated dashboard files as well as source text. +const packDirectory = mkdtempSync(join(tmpdir(), "burnlist-package-verify-")); +try { + const packed = spawnSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", packDirectory], { + cwd: repoRoot, encoding: "utf8", maxBuffer: 8 * 1024 * 1024, shell: false, + }); + if (packed.status !== 0) throw new Error(packed.stderr || "npm pack failed"); + const [{ filename }] = JSON.parse(packed.stdout); + const untar = spawnSync("tar", ["-xzf", join(packDirectory, filename), "-C", packDirectory], { encoding: "utf8", shell: false }); + if (untar.status !== 0) throw new Error(untar.stderr || "tar extraction failed"); + const actual = sourceFiles(join(packDirectory, "package"), join(packDirectory, "package")).sort(); + const expected = [...expectedPaths].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("packed tarball paths differ from npm dry-run manifest"); + for (const path of actual) { + const bytes = readFileSync(join(packDirectory, "package", path)); + const text = bytes.toString("utf8"); + if (forbiddenPayloadBytes.some((marker) => bytes.includes(Buffer.from(marker))) || forbiddenPayloadText.some((pattern) => pattern.test(text))) { + throw new Error(`packed tarball contains a forbidden personal path or prototype command: ${path}`); + } + } +} catch (error) { + console.error(`npm package byte verification failed: ${error.message}`); + process.exit(1); +} finally { + rmSync(packDirectory, { recursive: true, force: true }); +} + console.log(`npm package payload verified: ${report.entryCount} files, ${report.unpackedSize} bytes unpacked.`); diff --git a/scripts/verify-source-scan.mjs b/scripts/verify-source-scan.mjs new file mode 100644 index 00000000..152880b9 --- /dev/null +++ b/scripts/verify-source-scan.mjs @@ -0,0 +1,23 @@ +const EXCLUDED_PREFIXES = [ + ".git/", + ".claude/", + ".local/", + ".playwright-cli/", + ".worktrees/", + "build/", + "dist/", + "node_modules/", + "notes/burnlists/", + "output/", + "research/", + "website/.astro/", + "website/dist/", + "website/node_modules/", +]; + +export function shouldScanSourceRelativePath(path) { + const normalized = String(path).replaceAll("\\", "/"); + return !EXCLUDED_PREFIXES.some( + (prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix), + ); +} diff --git a/scripts/verify-source-scan.test.mjs b/scripts/verify-source-scan.test.mjs new file mode 100644 index 00000000..18dd8fbe --- /dev/null +++ b/scripts/verify-source-scan.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { shouldScanSourceRelativePath } from "./verify-source-scan.mjs"; + +test("source leak scanning excludes local and nested checkout state", () => { + for (const path of [ + ".git/config", + ".local/burnlist/state.json", + ".worktrees/feature/.git", + ".worktrees/feature/src/private.mjs", + "node_modules/package/index.js", + ]) { + assert.equal(shouldScanSourceRelativePath(path), false, path); + } +}); + +test("source leak scanning retains repository source and similarly named paths", () => { + for (const path of [ + "src/loops/view/render.mjs", + "scripts/verify.mjs", + "worktrees/committed-example.mjs", + "src/.git-example.mjs", + ]) { + assert.equal(shouldScanSourceRelativePath(path), true, path); + } +}); diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 590d0e65..5a1c2243 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -7,7 +7,6 @@ export const verificationTestFiles = [ "dashboard/src/lib/route-model.test.mjs", "src/server/dashboard-routes.test.mjs", "src/server/official-oven-discovery.test.mjs", - "src/server/oven-event-routes.test.mjs", "src/events/oven-event-feed.test.mjs", "src/events/oven-event-observer.test.mjs", "src/events/oven-canonical-mutations.test.mjs", @@ -74,7 +73,6 @@ export const verificationTestFiles = [ "src/cli/atomic-quarantine.test.mjs", "src/cli/skills-install-cli.test.mjs", "src/cli/skills-install-cli-purge.test.mjs", - "src/cli/commands-help.test.mjs", "src/cli/oven-cli.test.mjs", "src/cli/oven-data-flow-e2e.test.mjs", "src/cli/oven-set-cli.test.mjs", @@ -85,6 +83,33 @@ export const verificationTestFiles = [ "src/cli/oven-vendor-cli.test.mjs", "src/cli/umbrella.test.mjs", "src/cli/hooks-config.test.mjs", + "src/cli/loop-cli.test.mjs", + "src/cli/loop-runtime-cli.test.mjs", + "src/loops/config/config.test.mjs", + "src/loops/assignment/assignment.test.mjs", + "src/loops/dsl/compile.test.mjs", + "src/loops/dsl/package-read.test.mjs", + "src/loops/view/render.test.mjs", + "src/loops/capabilities/capabilities.test.mjs", + "src/loops/adapters/codex-cli.test.mjs", + "src/loops/adapters/normalized-invocation.test.mjs", + "src/loops/agents/profile.test.mjs", + "src/loops/contracts/contracts.test.mjs", + "src/loops/run/run-store.test.mjs", + "src/loops/run/controller.test.mjs", + "src/loops/run/current-authority.test.mjs", + "src/loops/run/launch-authority.test.mjs", + "src/loops/run/run-admission.test.mjs", + "src/loops/run/run-clock.test.mjs", + "src/loops/events/projection-events.test.mjs", + "src/loops/run/run-journal.test.mjs", + "src/loops/run/run-fold.test.mjs", + "src/loops/run/state-machine.test.mjs", + "src/loops/run/budgets.test.mjs", + "src/loops/run/runner.test.mjs", + "src/loops/run/binder.test.mjs", + "src/loops/run/reviewer-isolation.test.mjs", + "scripts/verify-source-scan.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", "src/server/oven-bindings.test.mjs", "src/server/oven-data-store.test.mjs", @@ -122,5 +147,9 @@ export const verificationTestFiles = [ // These subprocess wall-clock assertions must not compete with the parallel // test-file pool or host saturation can look like a hook timeout regression. export const verificationSerialTestFiles = [ + "src/server/oven-event-routes.test.mjs", + "src/cli/commands-help.test.mjs", + "src/loops/completion/completion.test.mjs", + "src/loops/minimal-review-e2e.test.mjs", "src/cli/streaming-diff-cli.test.mjs", ]; diff --git a/scripts/verify.mjs b/scripts/verify.mjs index fabf3d94..3d2416e2 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -7,6 +7,7 @@ import "../src/ovens/built-in-handlers.mjs"; import { loadOfficialOvenCatalog } from "../src/ovens/official-oven-catalog.mjs"; import { listOvenHandlers } from "../src/ovens/oven-registry.mjs"; import { assertBuiltInOven, assertBuiltInOvenDataDocs, assertBuiltInOvenSet, assertSkillSet } from "./verify-oven-assertions.mjs"; +import { shouldScanSourceRelativePath } from "./verify-source-scan.mjs"; import { verificationSerialTestFiles, verificationTestFiles } from "./verify-test-files.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -87,25 +88,9 @@ const leakPatterns = [ { name: "repo-specific term", pattern: new RegExp(`\\b(?:${repoSpecificTerms.join("|")})\\b`, "iu") }, ]; -const sourceScanExcludes = [ - ".git/", - ".claude/", - ".local/", - "build/", - "dist/", - "node_modules/", - ".playwright-cli/", - "notes/burnlists/", - "output/", - "research/", - "website/node_modules/", - "website/dist/", - "website/.astro/", -]; - function shouldScanSourceFile(path) { const normalized = relative(repoRoot, path).replace(/\\/g, "/"); - return !sourceScanExcludes.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix)); + return shouldScanSourceRelativePath(normalized); } function assertNoLeaks(label, text) { From c10dcad3666d4e023c3a6a2b8cec14918347a40a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 10:14:20 +0200 Subject: [PATCH 06/23] docs: document review loop setup and recovery --- README.md | 39 ++++ skills/burnlist/SKILL.md | 64 +++++++ .../references/loop-capability-example.json | 26 +++ website/astro.config.mjs | 2 +- website/src/content/docs/cli.mdx | 25 +++ website/src/content/docs/loops.mdx | 167 ++++++++++++++++++ 6 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 skills/burnlist/references/loop-capability-example.json create mode 100644 website/src/content/docs/loops.mdx diff --git a/README.md b/README.md index 8d9f7770..fc4008ef 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,45 @@ Untracked hook configs are added to `.git/info/exclude` by default; tracked conf Use `burnlist --help` for dashboard ports, scan roots, local state paths, and Oven data bindings. +## Review Loop (Stage 1) + +The built-in `review` Loop is an optional, serial foreground workflow for an +assigned item: + +```text +maker -> repository check -> fresh reviewer -> convergence gate -> CLI completion + ^ | + +------------ check failure / rejection +``` + +Configure a write-authority maker and read-authority reviewer with `burnlist +agent profile add`, map them with `burnlist route set`, inspect and explicitly +trust the repository check with `burnlist loop capability inspect|trust`, then +assign an item with `burnlist loop assign`. Run `burnlist loop view ` +and paste its complete ASCII output into the work handoff; it is the frozen +graph, pin, and completion-path record for that item. Create a Run with `loop create`, run +or resume it in the foreground with `loop run|resume`, and inspect it with +`loop status|inspect`. `pause` and `stop` are idle-Run recovery controls: they +require no active foreground owner. For a live foreground Run, Ctrl-C belongs +to that owner (first interrupt requests pause; second requests controlled +stop). Proof-gated `reconcile` is for a demonstrably lost owner. Only a +converged Run can be applied by `loop complete`; the +command is idempotent and performs the normal shrinking-list completion. + +The Checklist UI is read-only and shows the active node, attempt, results, +transition history, and paused, error, or terminal state. The runner enforces +the graph, fresh reviewer process, budgets, closed outcomes, and atomic CLI +writes. Reviewer filesystem write denial is **supervised**, not an OS sandbox. +Parallel execution, Docker isolation, metrics gates, custom adapters, +forecasting, worktrees, and background execution are deliberately unsupported +in Stage 1. Items with no Loop assignment keep the ordinary direct +`burnlist burn` workflow. + +See the [Loop CLI reference](website/src/content/docs/loops.mdx) for the exact +setup and recovery commands. Installing the Burnlist skill (`burnlist install`) +does not install Streaming Diff hooks (`burnlist hooks install`), and neither +is required to use the Loop. + ## Build and Verify From a source checkout: diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index da1b9698..00372043 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -151,3 +151,67 @@ Burnlist has two independent installable systems. Either or both may be present: - **Streaming Diff hooks** (`burnlist hooks install`) install per-repository edit-capture commands, not skills. Codex consumes `/.codex/hooks.json`; Claude Code consumes `/.claude/settings.json`. They invoke `burnlist streaming-diff hook` for session/edit events and merge with existing hook entries. Hooks have no global mode: use `burnlist hooks uninstall` or `burnlist hooks status` in the repository, optionally with `--agent codex,claude`. `--untracked` asks install to add the config to `.git/info/exclude`; it cannot hide an already tracked config. Install only the system the task needs, or both. Read `references/installation.md` for exact commands, ownership, and shared-versus-local behavior. + +## Built-in Review Loop (Stage 1) + +Use the built-in `loop:builtin:review` only when a user or active Burnlist +assigns an item to it. It is one serial foreground path: maker, trusted +repository check, fresh reviewer, convergence gate, then CLI-owned completion. +Items without an assignment keep the direct Burnlist workflow. + +Before creating a Run, configure distinct local profiles and routes, inspect +and explicitly trust the repository check capability, then assign the item: + +The installed skill includes `references/loop-capability-example.json`. Copy +its `catalog` object to the repository capability catalog and its `grants` +object to a private local grants path; the grants file can only narrow the +catalog. These are the copyable schemas, replacing +the absolute check command and paths with your repository's reviewed command: + +```json +{"schema":"burnlist-loop-capabilities@1","capabilities":[{"id":"repo-verify","argv":["/absolute/path/to/repository-check","--verify"],"cwd":".","environment":{"inherit":["PATH"],"set":{}},"network":"deny","filesystem":{"read":["src"],"write":[]},"output":{"maxBytes":65536},"maxMilliseconds":60000}]} +``` + +```json +{"argv":["/absolute/path/to/repository-check","--verify"],"cwd":".","environment":{"inherit":["PATH"],"set":{}},"network":"deny","filesystem":{"read":["src"],"write":[]},"output":{"maxBytes":65536},"maxMilliseconds":60000} +``` + +Accepted profile models are `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, +and `gpt-5.3-codex-spark`; efforts are `minimal`, `low`, `medium`, `high`, +`xhigh`, and `max`. + +```sh +burnlist agent profile add maker --adapter builtin:codex-cli --binary --model --effort --authority write +burnlist agent profile add reviewer --adapter builtin:codex-cli --binary --model --effort --authority read +burnlist route set implementation.standard --profile maker +burnlist route set review.strong --profile reviewer +burnlist loop capability inspect repo-verify +burnlist loop capability trust repo-verify --revision cp1-sha256: --grants +burnlist loop assign item:# loop:builtin:review +``` + +Run `burnlist loop setup status` before `loop create`. Paste the complete +`burnlist loop view item:#` ASCII output into the task +handoff or review request: it records the frozen graph, retries, completion +path, and pins. Control a Run only with `loop run|pause|resume|stop`, +inspect with `loop status|inspect`, use proof-gated `loop reconcile` only for a +demonstrably lost foreground owner, and apply a converged Run through the +idempotent `loop complete` command. A valid reconcile proof terminalizes as +`needs-human`; it never pauses a Run. + +Standalone `pause` and `stop` only work while no foreground owner holds the +Run lease. For live foreground work, the owner handles Ctrl-C: first interrupt +requests pause after its child exits, second requests controlled stop. Do not +claim that a separate CLI invocation can take over a live Run. + +Stage 1 labels fresh reviewer process, graph grammar, budgets, closed outcomes, +and atomic canonical CLI writes `enforced`; ordinary drift checks are +`detected-at-boundaries`; reviewer filesystem write denial is `supervised`. +It is not Docker or an OS sandbox. Parallelism, nested agents, metric gates, +custom adapters, worktrees, background execution, Docker isolation, and +forecasting are `unsupported`. The Checklist UI is read-only and displays +active, paused, error, and terminal Run states; it never controls a Run. + +`burnlist install` registers this skill, while `burnlist hooks install` adds +Streaming Diff edit-capture hooks. These integrations are independent and do +not configure or start a Review Loop. diff --git a/skills/burnlist/references/loop-capability-example.json b/skills/burnlist/references/loop-capability-example.json new file mode 100644 index 00000000..06ba7d01 --- /dev/null +++ b/skills/burnlist/references/loop-capability-example.json @@ -0,0 +1,26 @@ +{ + "catalog": { + "schema": "burnlist-loop-capabilities@1", + "capabilities": [ + { + "id": "repo-verify", + "argv": ["/absolute/path/to/repository-check", "--verify"], + "cwd": ".", + "environment": { "inherit": ["PATH"], "set": {} }, + "network": "deny", + "filesystem": { "read": ["src"], "write": [] }, + "output": { "maxBytes": 65536 }, + "maxMilliseconds": 60000 + } + ] + }, + "grants": { + "argv": ["/absolute/path/to/repository-check", "--verify"], + "cwd": ".", + "environment": { "inherit": ["PATH"], "set": {} }, + "network": "deny", + "filesystem": { "read": ["src"], "write": [] }, + "output": { "maxBytes": 65536 }, + "maxMilliseconds": 60000 + } +} diff --git a/website/astro.config.mjs b/website/astro.config.mjs index a9290bd4..13e0854f 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -45,7 +45,7 @@ export default defineConfig({ }, { label: 'Reference', - items: [{ slug: 'cli' }, { slug: 'dashboard' }], + items: [{ slug: 'cli' }, { slug: 'loops' }, { slug: 'dashboard' }], }, { label: 'Ovens', diff --git a/website/src/content/docs/cli.mdx b/website/src/content/docs/cli.mdx index 896b1a47..c96d03a0 100644 --- a/website/src/content/docs/cli.mdx +++ b/website/src/content/docs/cli.mdx @@ -37,6 +37,31 @@ These commands act on the current repository by default, or on an explicit `--re | `burnlist burn [--check] [--repo ]` | Complete a Burnlist item; `--check` validates it. | | `burnlist close [--repo ]` | Close a completed Burnlist. | +## Review Loop + +```sh +burnlist loop assign [--repo ] +burnlist loop unassign [--repo ] +burnlist loop view [--repo ] +burnlist loop create [--repo ] +burnlist loop run|pause|resume|stop|complete [--repo ] +burnlist loop list [--repo ] +burnlist loop status|inspect [--repo ] +burnlist loop reconcile --recovery-proof [--repo ] +burnlist loop capability inspect [--repo ] +burnlist loop capability trust --revision cp1-sha256: --grants [--repo ] +burnlist loop setup status [--repo ] +burnlist agent profile add --adapter builtin:codex-cli --binary --model --effort --authority read|write [--repo ] +burnlist agent doctor [--repo ] +burnlist route set --profile [--repo ] +``` + +The Review Loop is an optional serial foreground maker → check → fresh reviewer +workflow. `loop view` is deterministic ASCII graph output; `create`, control, +and read commands use Run references. Only `loop complete` applies a converged +Run to the Burnlist. See [Review Loop](/loops) for setup, recovery, visible UI +states, guarantee labels, and deferred features. + ## Registry ```sh diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx new file mode 100644 index 00000000..5b30169c --- /dev/null +++ b/website/src/content/docs/loops.mdx @@ -0,0 +1,167 @@ +--- +title: Review Loop +description: Configure and run the built-in serial maker, check, and reviewer Loop. +--- + +The built-in `loop:builtin:review` is an optional serial, foreground workflow: + +```text +Item -> maker -> check -> fresh reviewer -> gate -> CLI completion + ^ | + +-- fail/reject +``` + +It does not replace normal Burnlist execution. An unassigned item uses the +direct workflow, including `burnlist burn [--check]`; an assigned +item is completed only through its converged Loop Run. + +## Configure and assign + +Create one profile for each role, then attach the fixed routes. The maker has +write authority; the reviewer has read authority. `--binary` must be an +absolute path to the installed Codex CLI. Accepted `--model` values are +`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, and +`gpt-5.3-codex-spark`; accepted `--effort` values are `minimal`, `low`, +`medium`, `high`, `xhigh`, and `max`. + +```sh +burnlist agent profile add maker --adapter builtin:codex-cli --binary /absolute/path/to/codex --model --effort --authority write +burnlist agent profile add reviewer --adapter builtin:codex-cli --binary /absolute/path/to/codex --model --effort --authority read +burnlist route set implementation.standard --profile maker +burnlist route set review.strong --profile reviewer +burnlist agent doctor maker +burnlist agent doctor reviewer +``` + +The check is a repository-owned declarative capability, not a command in the +Loop package. Inspect it, review the grants JSON against that capability, then +trust the exact revision: + +First copy the catalog and grants objects below to `.burnlist/loop-capabilities.json` +and a private local grants path. Replace the absolute command +path and narrow the read paths to the check your repository actually needs. +The grants file deliberately repeats every field except `id` and may only +narrow this policy. + +```json title=".burnlist/loop-capabilities.json" +{ + "schema": "burnlist-loop-capabilities@1", + "capabilities": [{ + "id": "repo-verify", + "argv": ["/absolute/path/to/repository-check", "--verify"], + "cwd": ".", + "environment": { "inherit": ["PATH"], "set": {} }, + "network": "deny", + "filesystem": { "read": ["src"], "write": [] }, + "output": { "maxBytes": 65536 }, + "maxMilliseconds": 60000 + }] +} +``` + +```json title="grants.json" +{ + "argv": ["/absolute/path/to/repository-check", "--verify"], + "cwd": ".", + "environment": { "inherit": ["PATH"], "set": {} }, + "network": "deny", + "filesystem": { "read": ["src"], "write": [] }, + "output": { "maxBytes": 65536 }, + "maxMilliseconds": 60000 +} +``` + +```sh +burnlist loop capability inspect repo-verify +burnlist loop capability trust repo-verify --revision cp1-sha256: --grants /absolute/path/to/grants.json +burnlist loop setup status +burnlist loop assign item:# loop:builtin:review +``` + +`setup status` reports missing configuration rather than guessing it. An +assignment freezes the selected Loop authority for that item. View it and paste +the full output into the handoff or review request: + +```sh +burnlist loop view item:# +``` + +The ASCII view includes the decorative drawing, authoritative adjacency, pins, +retry limits, and completion path. It is the source of truth for the assigned +graph; do not redraw or abbreviate it. + +## Run and control + +Create a Run from the assigned item, then execute it in the foreground. These +commands return machine-readable status or projection JSON except `view`. + +```sh +burnlist loop create item:# +burnlist loop run run: +burnlist loop status run: +burnlist loop inspect run: +``` + +The maker receives the item and instructions, then the trusted repository +check runs. A new reviewer process evaluates the candidate. A check failure or +reviewer rejection returns to the maker within the declared budget; approval +passes the convergence gate. Escalation becomes `needs-human`; errors, +timeouts, cancellation, and budget exhaustion use their recorded terminal +outcomes. + +`Ctrl-C` requests a pause after the child exits; a second interrupt requests a +controlled stop. The standalone `pause` and `stop` commands require an idle +Run with no active foreground owner; they cannot take over a live Run. Use the +foreground owner's interrupts for a live Run, then use the standalone commands +only for recovery while it is idle: + +```sh +burnlist loop pause run: +burnlist loop resume run: +burnlist loop stop run: +burnlist loop reconcile run: --recovery-proof <64-lowercase-hex> +``` + +`reconcile` is for a demonstrably lost foreground owner. It folds the frozen +journal and never re-resolves setup, recompiles the Loop, or reruns work. +With a valid recovery proof it terminalizes the Run as `needs-human`; it never +changes a Run to paused. +Terminal stopped Runs can be replaced with a new `loop create`; executable +Runs cannot. + +## Completion and visibility + +Only a converged Run can request item completion: + +```sh +burnlist loop complete run: +``` + +The CLI verifies the still-current assignment and Run, updates the shrinking +checklist and ledger under its lifecycle lock, and records an idempotent +receipt. Repeating `complete` returns the existing receipt without burning the +item twice. + +The Checklist page is read-only. For a selected Loop item it exposes the graph +with the current node, attempt and cycle, latest result summary, +ordered transitions, and paused, error, and terminal states. It has no Run or +completion controls. + +## Guarantees and limits + +Stage 1 labels graph grammar, fresh reviewer process, budgets, closed outcomes, +atomic canonical CLI writes, and the dashboard read-only boundary as +`enforced`. Ordinary configuration and path drift checks are +`detected-at-boundaries`. Reviewer filesystem write denial and host +instructions are `supervised`; they are not Docker or an OS sandbox. + +Parallelism, nested agents, metric gates, custom adapters, auto-worktrees, +background execution, Docker isolation, and forecasting are `unsupported`. +Do not present any of those as shipped Loop features. + +## Skills and hooks are separate + +`burnlist install` makes the Burnlist skill discoverable. `burnlist hooks +install` installs optional per-repository Streaming Diff edit-capture hooks. +They are independent: either, both, or neither can be installed, and neither +configures or starts a Review Loop. See [Install](/install). From 75912df7dd335849f814e1e7a11df523093d1f92 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 17:50:18 +0200 Subject: [PATCH 07/23] feat: render item-scoped loops in checklist oven --- dashboard/src/App.tsx | 14 +- .../ChecklistDashboard/ChecklistDashboard.css | 9 +- .../ChecklistDashboard.test.mjs | 52 +-- .../ChecklistDashboard/ChecklistDashboard.tsx | 54 +--- .../ChecklistDashboard/ChecklistOvenView.tsx | 3 +- .../checklist-adapter.test.mjs | 1 + .../checklist-dom-golden.test.mjs | 14 +- .../checklist-dom.golden.html | 2 +- .../checklist-loop-progression.golden.json | 40 ++- .../checklist-loop-states.golden.json | 48 ++- .../CustomOvenView/CustomOvenView.tsx | 2 +- .../custom-oven-render.test.mjs | 12 +- .../DashboardError/DashboardError.css | 15 + .../DashboardError/DashboardError.tsx | 4 +- .../src/components/LoopGraph/LoopCompact.tsx | 95 ++++++ .../src/components/LoopGraph/LoopGraph.css | 117 +++++++ .../LoopGraph/LoopGraph.stories.tsx | 203 ++++++++++++ .../components/LoopGraph/LoopGraph.test.ts | 74 +++++ .../src/components/LoopGraph/LoopGraph.tsx | 124 ++++++++ .../src/components/LoopGraph/LoopLegend.tsx | 31 ++ .../components/LoopGraph/ascii-layout.test.ts | 37 +++ .../src/components/LoopGraph/ascii-layout.ts | 296 ++++++++++++++++++ .../components/LoopGraph/compact-layout.ts | 226 +++++++++++++ dashboard/src/components/LoopGraph/index.ts | 6 + .../src/components/LoopGraph/loop-symbols.ts | 49 +++ dashboard/src/hooks/dashboard-data.mjs | 3 +- dashboard/src/hooks/dashboard-data.test.mjs | 5 +- dashboard/src/hooks/useDashboardData.ts | 4 +- dashboard/src/lib/checklist-adapter.ts | 5 + dashboard/src/lib/hrefs.ts | 20 +- dashboard/src/lib/types.ts | 34 +- .../ChecklistCurrent/ChecklistCurrent.css | 79 +++++ .../ChecklistCurrent/ChecklistCurrent.test.ts | 41 +++ .../ChecklistCurrent/ChecklistCurrent.tsx | 43 +++ dashboard/src/oven/ChecklistCurrent/index.ts | 1 + .../ChecklistEventCards.tsx | 4 +- .../ChecklistWidgets/ChecklistWidgets.test.ts | 5 +- .../ChecklistWorkspace/ChecklistWorkspace.css | 110 +++++++ .../ChecklistWorkspace/ChecklistWorkspace.tsx | 106 +++++++ .../src/oven/ChecklistWorkspace/index.ts | 1 + dashboard/src/oven/runtime/OvenNode.test.ts | 20 ++ dashboard/src/oven/runtime/OvenNode.tsx | 18 +- dashboard/src/oven/runtime/OvenRuntime.tsx | 6 +- dashboard/src/oven/runtime/theme-registry.ts | 2 +- .../src/oven/runtime/widget-adapters.tsx | 2 + .../src/oven/test-support/run-oven-tests.mjs | 6 +- ovens/checklist/checklist.oven | 1 + scripts/package-paths.json | 6 +- scripts/verify-source-scan.mjs | 1 + scripts/verify-source-scan.test.mjs | 1 + skills/burnlist/SKILL.md | 13 + skills/burnlist/references/creating-ovens.md | 3 +- .../minimal-review-e2e-dom.golden.json | 30 +- src/loops/minimal-review-e2e.test.mjs | 11 +- src/loops/run/binder.test.mjs | 7 + src/loops/run/read-projection.mjs | 40 ++- src/loops/run/run-store.mjs | 13 +- src/ovens/dsl/oven-compile.test.mjs | 11 + src/ovens/dsl/oven-grammar.mjs | 18 +- src/ovens/dsl/oven-validate.mjs | 2 +- src/server/burnlist-dashboard-server.mjs | 45 ++- src/server/run-routes.test.mjs | 12 +- website/src/content/docs/loops.mdx | 43 ++- .../src/content/docs/ovens/dsl-reference.mdx | 16 + 64 files changed, 2139 insertions(+), 177 deletions(-) create mode 100644 dashboard/src/components/LoopGraph/LoopCompact.tsx create mode 100644 dashboard/src/components/LoopGraph/LoopGraph.css create mode 100644 dashboard/src/components/LoopGraph/LoopGraph.stories.tsx create mode 100644 dashboard/src/components/LoopGraph/LoopGraph.test.ts create mode 100644 dashboard/src/components/LoopGraph/LoopGraph.tsx create mode 100644 dashboard/src/components/LoopGraph/LoopLegend.tsx create mode 100644 dashboard/src/components/LoopGraph/ascii-layout.test.ts create mode 100644 dashboard/src/components/LoopGraph/ascii-layout.ts create mode 100644 dashboard/src/components/LoopGraph/compact-layout.ts create mode 100644 dashboard/src/components/LoopGraph/index.ts create mode 100644 dashboard/src/components/LoopGraph/loop-symbols.ts create mode 100644 dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.css create mode 100644 dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.test.ts create mode 100644 dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.tsx create mode 100644 dashboard/src/oven/ChecklistCurrent/index.ts create mode 100644 dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css create mode 100644 dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx create mode 100644 dashboard/src/oven/ChecklistWorkspace/index.ts diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d5509e5e..24d66978 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ListChecks } from "lucide-react"; import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, OvenCatalog, OvenDefinition, OvenExplainer, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; import { useDashboardData } from "@hooks"; @@ -8,7 +8,13 @@ import { Button } from "@layout"; export function App() { const section = currentSection(); - const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search]); + const [itemHash, setItemHash] = useState(() => window.location.hash); + useEffect(() => { + const changed = () => setItemHash(window.location.hash); + window.addEventListener("hashchange", changed); + return () => window.removeEventListener("hashchange", changed); + }, []); + const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search, itemHash]); const repoKey = ovenRepoKey(); const [filter, setFilter] = useState(() => filterFromUrl(FILTERS)); const dashboardSection = ["landing", "burnlist", "streaming-diff"].includes(section) || (section === "custom-oven" && selected) ? "burnlists" : section; @@ -34,7 +40,7 @@ export function App() {
{section === "differential-testing" ? {(ir) => } : section === "model-lab" ? {(ir) => } : section === "performance-tracing" ? {(ir) => } : section === "streaming-diff" ? {(ir) => } : section === "visual-parity" ? {(ir) => } : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ? : section === "oven-explainer" ? : selected ? ( loading && !progress ? : progress ? ( - <>{(error || stale) && }{(ir) => } + <>{(error || stale) && }{(ir) => } ) : error ? : ) : (
@@ -48,7 +54,7 @@ export function App() { - {(error || stale) && } + {(error || stale) && } {projects.length && visibleBurnlistCount ? (
{projects.map((project) => )}
) : projects.length ? : error ? null : } diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css index f0e6fa2b..b094dd65 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css @@ -15,14 +15,16 @@ body.checklist-detail-view .dashboard-oven-title { .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-ratio { font-variant-numeric: tabular-nums; } .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-progress-donut-segment { transition: stroke-dasharray 160ms ease; } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace { width: 100%; height: 232px; min-height: 232px; max-height: 232px; flex: 0 0 232px; - grid-template-columns: 30% minmax(0, 70%); + grid-template-columns: minmax(220px, 26%) minmax(360px, 48%) minmax(220px, 26%); +} +.checklist-detail-shell #burnlist-detail .checklist-progress-workspace:not(:has(.checklist-current)) { + grid-template-columns: minmax(220px, 30%) minmax(360px, 70%); } /* The shared Differential Testing shell defaults its top workspace to 310px. */ @@ -43,7 +45,8 @@ body.checklist-detail-view .dashboard-oven-title { } .checklist-detail-shell .checklist-progress-workspace .event-ledger-panel, -.checklist-detail-shell .checklist-progress-workspace .progress-panel { +.checklist-detail-shell .checklist-progress-workspace .progress-panel, +.checklist-detail-shell .checklist-progress-workspace .checklist-current { height: 232px; min-height: 232px; max-height: 232px; diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index a588c14e..612c837a 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -28,13 +28,12 @@ test("checklist detail renders the split progress surface and event card list", entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", }); - const { ChecklistDashboard, LoopRunPanel, checklistEventDetailFields } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); + const { ChecklistDashboard, LoopRunPanel } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); const markup = renderToStaticMarkup(createElement(ChecklistDashboard, { data })); assert.equal(markup, renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...data, loopRun: null } }))); assert.match(markup, /aria-label="Burnlist progress KPIs"/u); - assert.match(markup, /class="driving-parity-kpi-item driving-parity-kpi-section checklist-kpi-current"/u); - assert.match(markup, /
Current<\/div>
Complete<\/div>/u); + assert.doesNotMatch(markup, /class="panel checklist-current"/u); assert.match(markup, /class="driving-parity-kpi-item driving-parity-kpi-section driving-parity-kpi-progress"/u); assert.match(markup, /
2<\/span>·<\/span>2<\/span> \(100%\)<\/span><\/div>/u); assert.match(markup, /
Elapsed<\/div>/u); @@ -46,27 +45,13 @@ test("checklist detail renders the split progress surface and event card list", assert.match(markup, /Completion<\/span>/u); assert.doesNotMatch(markup, /aria-label="Burnlist progress chart view"/u); assert.match(markup, /Age<\/span>Event<\/span>Result<\/span>Delta<\/span>Done<\/span>/u); - assert.match(markup, /class="event-card-list"/u); - assert.equal((markup.match(/data-event-card="true"/gu) ?? []).length, 2); + assert.match(markup, /class="checklist-workspace"/u); + assert.match(markup, /aria-label="Completed events"/u); + assert.match(markup, /aria-label="Remaining items"/u); + assert.match(markup, /class="checklist-workspace__empty">No active item/u); + assert.equal((markup.match(/class="checklist-workspace__event"/gu) ?? []).length, 2); assert.equal(markup.indexOf("Second event") < markup.indexOf("First event"), true); - assert.match(markup, /First proof\./u); - assert.match(markup, /Second proof\./u); - assert.equal((markup.match(/class="event-card-field-label">Outcome/gu) ?? []).length, 2); - assert.equal((markup.match(/class="event-card-summary"/gu) ?? []).length, 2); - assert.equal((markup.match(/class="event-card-description"/gu) ?? []).length, 2); - assert.match(markup, /
Changed<\/span>1<\/span><\/summary>/u); - assert.match(markup, /
Proof<\/span>1<\/span><\/summary>/u); - assert.match(markup, /src\/second\.mjs/u); - assert.match(markup, /node --test second\.test\.mjs/u); - assert.match(markup, /class="event-card-field-label">Follow-up/u); - assert.doesNotMatch(markup, /event-card-cell|event-card-content|event-card-expand/u); - assert.deepEqual(checklistEventDetailFields(data.completed[1].detail), [ - { label: "Completed", values: ["2026-07-15T11:50:00Z"] }, - { label: "Changed", values: ["src/second.mjs"] }, - { label: "Proof", values: ["node --test second.test.mjs"] }, - { label: "Outcome", values: ["Second proof."] }, - { label: "Follow-up", values: ["None."] }, - ]); + assert.doesNotMatch(markup, /data-event-card="true"/u); assert.doesNotMatch(markup, /Completed: 2026/u); assert.doesNotMatch(markup, />DONE]*>Changes<\/button>/u); @@ -79,14 +64,13 @@ test("checklist detail renders the split progress surface and event card list", }); for (const projection of snapshots) { const stage = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, loopRun: projection } })); - assert.match(stage, new RegExp(`Current ${projection.currentNode} · attempt ${projection.attempt} · cycle ${projection.cycle}`, "u")); - assert.match(stage, /aria-label="Loop graph edges"/u); - assert.match(stage, /implement<\/strong> —complete→<\/span> verify<\/strong>/u); + assert.match(stage, new RegExp(`ACTIVE: ${projection.currentNode.toUpperCase()}`, "u")); + assert.match(stage, /IMPLEMENT/u); + assert.match(stage, /VERIFY/u); assert.match(stage, /aria-current="step"/u); - if (projection.latestResult) assert.match(stage, new RegExp(`Latest ${projection.latestResult.kind} · ${projection.latestResult.summary}`, "u")); - if (projection.currentNode === "implement" && projection.attempt === 2) assert.match(stage, /review —reject→<\/span> implement/u); - if (projection.currentNode === "converged") assert.match(stage, /review —approve→<\/span> converged/u); - if (projection.currentNode === "completed") assert.match(stage, /converged —pass→<\/span> completed/u); + if (projection.currentNode === "implement" && projection.attempt === 2) assert.match(stage, /reject/u); + if (projection.currentNode === "converged") assert.match(stage, /approve/u); + if (projection.currentNode === "completed") assert.match(stage, /COMPLETED/u); } const evidence = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, @@ -97,11 +81,8 @@ test("checklist detail renders the split progress surface and event card list", latestReviewer: { summary: "approved", at: Date.parse("2026-07-15T11:50:00Z"), candidateId: "candidate-1" }, }, } })); - assert.match(evidence, /aria-label="Latest role evidence"/u); - assert.match(evidence, /
Maker<\/dt>
candidate prepared/u); - assert.match(evidence, /
Check<\/dt>
verify passed/u); - assert.match(evidence, /
Reviewer<\/dt>
approved/u); - assert.match(evidence, /candidate candidate-1/u); + assert.match(evidence, /ACTIVE: COMPLETED/u); + assert.match(evidence, /aria-label="Loop state: Converged"/u); } finally { await rm(outputDir, { force: true, recursive: true }); if (repoRoot) await rm(repoRoot, { force: true, recursive: true }); @@ -121,7 +102,6 @@ test("Loop panel exposes every terminal and observer diagnostic state accessibly const projection = { ...final, state }; const markup = renderToStaticMarkup(createElement(LoopRunPanel, { data: { ...data, loopRun: state === "stale" ? { ...projection, diagnostic: "stale" } : projection } })); assert.match(markup, new RegExp(`aria-label="Loop state: ${label}"`, "u")); - assert.match(markup, /aria-label="Loop budget"/u); } } finally { await rm(outputDir, { force: true, recursive: true }); diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index 27ca8de1..4a95ca0d 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -5,6 +5,9 @@ import { checklistEventDetailFields, compactAge, eventRows, formatDuration, prog export { checklistEventDetailFields } from "@lib/checklist-adapter"; import "./ChecklistDashboard.css"; import { buildChecklistProgressChart, KpiItem, KpiStrip, LogTable, ProgressDonut, SectionHeader } from "@oven"; +import { LoopGraph } from "@/components/LoopGraph"; +import { ChecklistCurrent } from "@/oven/ChecklistCurrent"; +import { ChecklistWorkspace } from "@/oven/ChecklistWorkspace"; function ChecklistKpis({ data }: { data: ChecklistProgressData }) { const durations = timing(data); @@ -95,49 +98,12 @@ export function EventCardList({ data }: { data: ChecklistProgressData }) { } export function LoopRunPanel({ data }: { data: ChecklistProgressData }) { - const run = data.loopRun; - if (!run) return data.loopProjectionDiagnostic ?
-
Loop Run
Corrupt projection
-

{data.loopProjectionMessage || "The Loop projection is corrupt. Progress remains available while the dashboard waits for a verified projection."}

-
: null; - const stateLabel: Record = { - paused: "Paused", failed: "Failed", stopped: "Stopped", "needs-human": "Needs human review", - "budget-exhausted": "Budget exhausted", corrupt: "Corrupt projection", stale: "Stale projection", converged: "Converged", completed: "Completed", - }; - const state = run.diagnostic === "corrupt" ? "Corrupt projection" : run.diagnostic === "stale" ? "Stale projection" : stateLabel[run.state] ?? run.state; - const budget = run.budget; - const nodeById = new Map(run.graph.nodes.map((node) => [node.id, node])); - const orderedEdges = [...run.graph.edges].sort((left, right) => { - const stage = (edge: typeof left) => edge.from === "implement" ? 0 : edge.from === "verify" ? 1 : edge.from === "review" ? 2 : edge.from === "converged" ? 3 : 4; - return stage(left) - stage(right) || left.from.localeCompare(right.from) || left.on.localeCompare(right.on); - }); - const roleResults = [ - ["Maker", run.latestMaker], ["Check", run.latestCheck], ["Reviewer", run.latestReviewer], - ] as const; - return
-
Loop Run
- {state}
-

{run.loopId}{run.loopRevision && {run.loopRevision}}

-
Current {run.currentNode} · attempt {run.attempt} · cycle {run.cycle}
-
-
Elapsed
{formatDuration(budget.elapsedMilliseconds)}
-
Rounds
{budget.counters.rounds}/{budget.limits.maxRounds}
-
Agent runs
{budget.counters.agentRuns}/{budget.limits.maxAgentRuns}
-
Checks
{budget.counters.checkRuns}/{budget.limits.maxCheckRuns}
-
Transitions
{budget.counters.transitions}/{budget.limits.maxTransitions}
-
-
    {run.graph.nodes.map((node) => -
  1. - {node.id}{node.kind} -
  2. )}
-
    {orderedEdges.map((edge) => -
  1. {nodeById.get(edge.from)?.id ?? edge.from} —{edge.on}→ {nodeById.get(edge.to)?.id ?? edge.to}
  2. )}
- {roleResults.some(([, result]) => result) &&
{roleResults.map(([role, result]) => result && -
{role}
{result.summary} {result.candidateId && candidate {result.candidateId}}
)}
} - {run.latestResult &&

Latest {run.latestResult.kind} · {run.latestResult.summary}

} -
    {run.transitions.map((transition) => -
  1. {transition.from} —{transition.outcome}→ {transition.to}
  2. )}
-
; + return ; } export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { @@ -145,5 +111,5 @@ export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { document.body.classList.add("driving-parity-view", "checklist-detail-view"); return () => document.body.classList.remove("driving-parity-view", "checklist-detail-view"); }, []); - return
; + return
; } diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx index 9c518aac..65e8dfb1 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx @@ -3,7 +3,6 @@ import type { ResolvedOvenIr } from "@hooks"; import type { ChecklistProgressData } from "@lib"; import { adaptChecklist } from "@lib/checklist-adapter"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; -import { LoopRunPanel } from "./ChecklistDashboard"; import "./ChecklistDashboard.css"; export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; ir: ResolvedOvenIr }) { @@ -12,5 +11,5 @@ export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; i document.body.classList.add("driving-parity-view", "checklist-detail-view"); return () => document.body.classList.remove("driving-parity-view", "checklist-detail-view"); }, []); - return <>; + return ; } diff --git a/dashboard/src/components/ChecklistDashboard/checklist-adapter.test.mjs b/dashboard/src/components/ChecklistDashboard/checklist-adapter.test.mjs index ce523387..e7be74c6 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-adapter.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/checklist-adapter.test.mjs @@ -15,6 +15,7 @@ test("adaptChecklist precomputes the checklist oven payload", async () => { const { adaptChecklist } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); assert.deepEqual(adaptChecklist(checklistFixture), { raw: checklistFixture, + items: [], current: { value: "Complete", title: "No active task" }, progress: { done: 2, total: 2, percent: 100, title: "2 of 2 tasks complete" }, durations: { elapsed: "10m", pace: "5m", timeLeft: "0m" }, diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs index e6050477..c2451c9d 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom-golden.test.mjs @@ -18,6 +18,16 @@ const goldenPath = new URL("./checklist-dom.golden.html", import.meta.url); const loopGoldenPath = new URL("./checklist-loop-progression.golden.json", import.meta.url); const loopStateGoldenPath = new URL("./checklist-loop-states.golden.json", import.meta.url); const digest = (value) => createHash("sha256").update(value).digest("hex"); +const itemData = (projection) => ({ + ...checklistFixture, + active: [{ + id: projection.itemRef.split("#").at(-1), + title: "Loop-assigned item", + fields: {}, + loop: { selector: `loop:builtin:${projection.loopId}` }, + }], + loopRun: projection, +}); test("checklist detail static DOM matches the frozen byte golden", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".checklist-dom-golden-test-")); @@ -70,7 +80,7 @@ test("real M4 projections advance the full Checklist DOM through the frozen Loop const actual = selected.map((projection) => { const projectionBytes = JSON.stringify({ ...projection, revision: "" }); const markup = withDeterministicTime(() => - renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, loopRun: projection } }))); + renderToStaticMarkup(createElement(ChecklistDashboard, { data: itemData(projection) }))); const domBytes = serializeCanonical(normalize(parseHtml(markup))); return { checkpoint: `${projection.currentNode}/${projection.attempt}/${projection.latestResult?.kind ?? "none"}`, @@ -112,7 +122,7 @@ test("terminal, paused, stale, and corrupt Loop states retain frozen full Checkl ]; const actual = variants.map(([checkpoint, patch]) => { const markup = withDeterministicTime(() => renderToStaticMarkup(createElement(ChecklistDashboard, { - data: { ...checklistFixture, loopRun: { ...final, ...patch } }, + data: itemData({ ...final, ...patch }), }))); const domBytes = serializeCanonical(normalize(parseHtml(markup))); return { checkpoint, domBytes: Buffer.byteLength(domBytes), domSha256: digest(domBytes) }; diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html index f381bfee..ae9fd828 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html @@ -1 +1 @@ -
Current
Complete
Progress
2·2(100%)
Elapsed
10m
Avg pace
5m
Time left
0m
Progress
AgeEventResultDeltaDone
10mB2Done+1100%
20mB1Done+150%
Completion
25%50%75%100%11:4211:4411:4611:48Completed 1 itemCompleted 1 item

Events(2)

B2Second event·100%
Changed1
  • src/second.mjs
Proof1
  • node --test second.test.mjs
Outcome

Second proof.

Follow-up

None.

B1First event·50%
Outcome

First proof.

+
Current
Complete
Progress
2·2(100%)
Elapsed
10m
Avg pace
5m
Time left
0m
Progress
AgeEventResultDeltaDone
10mB2Done+1100%
20mB1Done+150%
Completion
25%50%75%100%11:4211:4411:4611:48Completed 1 itemCompleted 1 item
Events2
B2Second event10m · 100%
B1First event20m · 50%
Items0

No active item

diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json index 2729a30a..2b0d3d77 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -1,7 +1,37 @@ [ - { "checkpoint": "implement/1/reject", "projectionBytes": 1855, "projectionSha256": "bb919c8828f97eb63e5c6e29b2eb73736e3a24e476ea13190d39ac4fc329fd3c", "domBytes": 12119, "domSha256": "70d9d519964b9f9b292bf02bed768355e95eb126b6ba254cd7562ba3825855ab" }, - { "checkpoint": "implement/2/reject", "projectionBytes": 1855, "projectionSha256": "9c623f1ccf612c9921f93c031ac4c7757892d2eda3feb46c34dfbd6347957b94", "domBytes": 12119, "domSha256": "cd341ac15a227617969bdb3e66cd9d23fef58075f6c31e26b35b78efdae49270" }, - { "checkpoint": "review/2/approve", "projectionBytes": 1990, "projectionSha256": "318079e10fef419d1985aa704b8c5bb77791161a57b6771dc6d5cde5e005ddea", "domBytes": 12214, "domSha256": "beb873ef0164fe58e3b635a3082dcebb2f191882ea8992b6aa71c32283154683" }, - { "checkpoint": "converged/1/approve", "projectionBytes": 2062, "projectionSha256": "1a2c67ec6c1645b46c690d0531a32b68b679191531f6fa6a5cf333f66ffcf967", "domBytes": 12267, "domSha256": "7427a07760115ac129912c66dcb03fd619051b7fa5d69e08f352a1c99a9251a5" }, - { "checkpoint": "completed/1/approve", "projectionBytes": 2133, "projectionSha256": "c48da3b51874c7daba7669a4ded690445d89b9eb1b7b311f061b60472f54cc07", "domBytes": 12321, "domSha256": "1ff2c2747b11031e8c3d89fbf9ad15d87bfe5f292c57161123685fd62ca0f83b" } + { + "checkpoint": "implement/1/reject", + "projectionBytes": 2157, + "projectionSha256": "6e8e57daad385abd423cc1849253359d322d7253db5bfc788cea632c64b2cc4a", + "domBytes": 10215, + "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + }, + { + "checkpoint": "implement/2/reject", + "projectionBytes": 2157, + "projectionSha256": "f0f0bc7fad845f804893d6b193c80926a2245b8174a6e7787eb9ded01f6069e7", + "domBytes": 10215, + "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + }, + { + "checkpoint": "review/2/approve", + "projectionBytes": 2292, + "projectionSha256": "dfc91fde48e5a690f5d7974b1320828376d9dac73a92f96a835bceb3a3186355", + "domBytes": 10212, + "domSha256": "dad8893e2e1194cf690929501441b9182678f664a97bea9375c15fea698ec98b" + }, + { + "checkpoint": "converged/1/approve", + "projectionBytes": 2364, + "projectionSha256": "9985718fe0bda762ac030d9d282ac51682819e53dcd9947039ad2ba2a4e84a9f", + "domBytes": 10215, + "domSha256": "f27683bf4be303b13cbff2a7dbab54535076f506ed109e2e11d3952b92a0aa80" + }, + { + "checkpoint": "completed/1/approve", + "projectionBytes": 2435, + "projectionSha256": "638a6ba72885ebf7f130bb2dc31fd1d7a685eea85b5b9e0a1d7eb84c6f31c424", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + } ] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json index 44ed019c..7d0c7a88 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,10 +1,42 @@ [ - { "checkpoint": "paused", "domBytes": 12171, "domSha256": "46388c6ac8e544fbf4b511f0178c2d0adfbc8cc41c6a37375cce7e7b0f4b20c8" }, - { "checkpoint": "failed", "domBytes": 12171, "domSha256": "de66441d38526255a307f54d4eead2c5d0e0d3d2f58d70a0a00e0f8614ee26b0" }, - { "checkpoint": "stopped", "domBytes": 12173, "domSha256": "e306f86ff9bbcc7294e6b9bc3df3987989f796bd947d789f4d1edddca83efa9f" }, - { "checkpoint": "needs-human", "domBytes": 12195, "domSha256": "8a25e97a8f6fee72f336ab4ecbc859964d0b4ff664e63e32efba0e57a83d31c5" }, - { "checkpoint": "exhausted", "domBytes": 12191, "domSha256": "46df3aa30b14a0086f8299895e69fad120e6564bf65b2119cac652b6ec793c10" }, - { "checkpoint": "stale", "domBytes": 12191, "domSha256": "afc4e0cebcd3a7f3bb1fad8d32ae733b9d2bc9c2a0a9910bc1ad37ff39fc82f9" }, - { "checkpoint": "corrupt", "domBytes": 12195, "domSha256": "b6540a7baf33695218d768a9183c89b13c237f677ef825888270ce80598f23aa" }, - { "checkpoint": "completed", "domBytes": 12177, "domSha256": "27ef2ca6d8357bd24166c1954220e3f7c75f60361e574f16806a72fa3b9d2ace" } + { + "checkpoint": "paused", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "failed", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "stopped", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "needs-human", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "exhausted", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "stale", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "corrupt", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "completed", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + } ] diff --git a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx index c3077328..962cbfc6 100644 --- a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx +++ b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx @@ -27,7 +27,7 @@ export function CustomOvenView({ error, loading, progress, stale }: { error: str if (selection.burnlistId && loading && !progress) return ; if (selection.burnlistId && !progress) return error ? : ; return <> - {selection.burnlistId && (error || stale) && } + {selection.burnlistId && (error || stale) && } {(ir) => ( + `; test("custom Oven runtime modes use canonical live snapshots and controlled Burnlist data", { timeout: 20_000 }, async () => { @@ -36,7 +37,14 @@ test("custom Oven runtime modes use canonical live snapshots and controlled Burn assert.equal(compiled.ok, true, compiled.ok ? "" : JSON.stringify(compiled.diagnostics)); if (!compiled.ok) return; - const payload = { widget: { name: "Sprockets", count: 42 } }; + const payload = { + widget: { name: "Sprockets", count: 42 }, + loopRun: { + loopId: "review", state: "running", currentNode: "verify", attempt: 1, cycle: 0, + graph: { entry: "implement", nodes: [{ id: "implement", kind: "agent" }, { id: "verify", kind: "check" }], edges: [{ from: "implement", on: "complete", to: "verify" }] }, + transitions: [{ sequence: 1, from: "implement", outcome: "complete", to: "verify" }], + }, + }; const ir = { ...compiled.ir, refreshSeconds: 7 }; const standalone = CustomOvenRuntime({ ir }); assert.equal(standalone.props.ir, ir); @@ -53,6 +61,8 @@ test("custom Oven runtime modes use canonical live snapshots and controlled Burn assert.equal("initialPayload" in controlled.props, false); assert.equal(controlled.props.ir.refreshSeconds, undefined); assert.equal(controlled.props.adapt, undefined); + assert.match(renderToStaticMarkup(burnlist), /aria-current="step"/u); + assert.match(renderToStaticMarkup(burnlist), /VERIFY/u); } finally { await rm(outputDir, { force: true, recursive: true }); } diff --git a/dashboard/src/components/DashboardError/DashboardError.css b/dashboard/src/components/DashboardError/DashboardError.css index 5b0d2618..b6fb980a 100644 --- a/dashboard/src/components/DashboardError/DashboardError.css +++ b/dashboard/src/components/DashboardError/DashboardError.css @@ -2,3 +2,18 @@ .dashboard-error-content { display: flex; gap: 12px; padding: 0 20px; } .dashboard-error-icon { width: 20px; height: 20px; flex: 0 0 auto; } .dashboard-error-message { margin: 0; font-size: 14px; } +.dashboard-error--floating { + position: fixed; + z-index: 60; + top: 58px; + right: 16px; + width: min(480px, calc(100vw - 32px)); + padding: 10px 0; + border-color: rgba(239, 68, 68, .28); + background: rgba(36, 18, 18, .96); + box-shadow: 0 8px 28px rgba(0, 0, 0, .38); + pointer-events: none; +} +.dashboard-error--floating .dashboard-error-content { align-items: center; padding: 0 12px; } +.dashboard-error--floating .dashboard-error-icon { width: 16px; height: 16px; } +.dashboard-error--floating .dashboard-error-message { font-size: 12px; line-height: 1.35; } diff --git a/dashboard/src/components/DashboardError/DashboardError.tsx b/dashboard/src/components/DashboardError/DashboardError.tsx index d89cf121..1d6a3dcd 100644 --- a/dashboard/src/components/DashboardError/DashboardError.tsx +++ b/dashboard/src/components/DashboardError/DashboardError.tsx @@ -2,9 +2,9 @@ import { AlertTriangle } from "lucide-react"; import { Card, CardContent } from "@layout"; import "./DashboardError.css"; -export function DashboardError({ message }: { message: string }) { +export function DashboardError({ message, floating = false }: { message: string; floating?: boolean }) { return ( - +
V<\/dt>
VERIFY<\/strong> · test · repo-verify/u); + const labeled = renderToStaticMarkup(createElement(LoopCompact, { + run, labels: "outcomes", symbols: { implement: "M", verify: "T" }, + })); + assert.match(labeled, /M.*success.*T/u); + assert.match(labeled, /fail/u); +}); diff --git a/dashboard/src/components/LoopGraph/LoopGraph.tsx b/dashboard/src/components/LoopGraph/LoopGraph.tsx new file mode 100644 index 00000000..bb84f88c --- /dev/null +++ b/dashboard/src/components/LoopGraph/LoopGraph.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useState } from "react"; +import { layoutAsciiGraph } from "./ascii-layout"; +import "./LoopGraph.css"; + +export type LoopGraphNode = { + id: string; + kind: string; + role?: string; + authority?: "write" | "read"; + capability?: string; + gateKind?: string; + measure?: "test" | "metric" | "eval" | "boolean"; + target?: string; + terminalState?: string; + execution?: null | { + profileId: string; + model: string; + effort: string; + authority: "write" | "read"; + }; +}; +export type LoopGraphEdge = { from: string; on: string; to: string }; +export type LoopGraphTransition = { sequence: number; from: string; outcome: string; to: string }; +export type LoopGraphProjection = { + itemRef?: string; + loopId: string; + state: string; + currentNode: string; + attempt: number; + cycle: number; + graph: { entry?: string; nodes: LoopGraphNode[]; edges: LoopGraphEdge[] }; + transitions?: LoopGraphTransition[]; + latestResult?: null | { kind: string; summary: string }; + budget?: { + limits: { maxRounds: number; maxMinutes: number; maxAgentRuns: number; maxCheckRuns: number; maxTransitions: number; maxOutputBytes: number }; + counters: { rounds: number; agentRuns: number; checkRuns: number; transitions: number; outputBytes: number }; + elapsedMilliseconds: number; + journal: { maximum: number; used: number; remaining: number }; + }; + latestMaker?: null | { summary: string; at: number; candidateId: string | null }; + latestCheck?: null | { summary: string; at: number; candidateId: string | null }; + latestReviewer?: null | { summary: string; at: number; candidateId: string | null }; +}; + +export type LoopGraphProps = { + run?: LoopGraphProjection | null; + diagnostic?: "corrupt" | "stale"; + message?: string; + title?: string; +}; + +function presentationState(run: LoopGraphProjection, diagnostic?: "corrupt" | "stale") { + if (diagnostic === "corrupt") return "error"; + if (diagnostic === "stale") return "stale"; + if (["completed", "converged"].includes(run.state)) return "converged"; + if (["failed", "stopped", "needs-human", "budget-exhausted", "corrupt"].includes(run.state)) return "error"; + if (run.state === "prepared") return "prepared"; + if (run.state === "paused") return "paused"; + if (run.cycle > 0) return "repair"; + return "running"; +} + +function stateLabel(run: LoopGraphProjection, diagnostic?: "corrupt" | "stale") { + if (diagnostic === "corrupt" || run.state === "corrupt") return "Corrupt projection"; + if (diagnostic === "stale") return "Stale projection"; + const labels: Record = { + paused: "Paused", failed: "Failed", stopped: "Stopped", "needs-human": "Needs human review", + "budget-exhausted": "Budget exhausted", converged: "Converged", completed: "Completed", + }; + return labels[run.state] ?? "Running"; +} + +function GraphCanvas({ run, label, state }: { run: LoopGraphProjection; label: string; state: string }) { + const host = useRef(null); + const text = useRef(null); + const [characters, setCharacters] = useState(72); + useEffect(() => { + if (!host.current || !text.current || typeof ResizeObserver === "undefined") return; + const style = getComputedStyle(text.current); + const context = document.createElement("canvas").getContext("2d"); + if (context) context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const characterWidth = Math.max(1, (context?.measureText("0000000000").width ?? 90) / 10); + const observer = new ResizeObserver(([entry]) => + setCharacters(Math.max(36, Math.floor(entry.contentRect.width / characterWidth) - 2))); + observer.observe(host.current); + return () => observer.disconnect(); + }, []); + const layout = layoutAsciiGraph(run.graph, run.currentNode, characters); + const active = run.graph.nodes.find((node) => node.id === run.currentNode); + const execution = active?.execution ? `; model ${active.execution.model}; effort ${active.execution.effort}; authority ${active.execution.authority}` : ""; + const activeDetail = active?.execution + ? `${active.execution.model} · ${active.execution.effort} · ${active.execution.authority}` + : active?.kind === "check" ? `${active.measure ?? "test"} · ${active.capability ?? "deterministic"}` + : active?.kind === "gate" ? `${active.measure ?? "gate"}${active.target ? ` · ${active.target}` : ""}` + : active?.kind ?? ""; + return
+
ACTIVE: {run.currentNode.toUpperCase()}{activeDetail ? ` · ${activeDetail}` : ""}
+
+    {layout.lines.map((value, row) => {
+      const current = layout.current;
+      if (!current || row < current.y || row > current.y + 2)
+        return {value}{"\n"};
+      return 
+        {value.slice(0, current.x)}{value.slice(current.x, current.x + current.width)}{value.slice(current.x + current.width)}{"\n"}
+      ;
+    })}
+    
+
; +} + +export function LoopGraph({ run, diagnostic, message, title = "Loop Run" }: LoopGraphProps) { + if (!run) { + if (!diagnostic) return null; + const diagnosticLabel = diagnostic === "corrupt" ? "Corrupt projection" : "Stale projection"; + return
+      {`┌─ LOOP UNAVAILABLE ─┐\n│ ${diagnosticLabel}: ${message ?? "Projection unavailable."}\n└────────────────────┘`}
+    
; + } + + const viewState = presentationState(run, diagnostic); + return
+ +
; +} diff --git a/dashboard/src/components/LoopGraph/LoopLegend.tsx b/dashboard/src/components/LoopGraph/LoopLegend.tsx new file mode 100644 index 00000000..b8ffa18c --- /dev/null +++ b/dashboard/src/components/LoopGraph/LoopLegend.tsx @@ -0,0 +1,31 @@ +import type { LoopGraphProjection } from "./LoopGraph"; +import { loopSymbols } from "./loop-symbols"; +import "./LoopGraph.css"; + +export type LoopLegendProps = { + run?: LoopGraphProjection | null; + title?: string; + symbols?: Record; +}; + +function description(node: LoopGraphProjection["graph"]["nodes"][number]) { + if (node.id === "start") return "item input"; + if (node.execution) { + return `${node.execution.model} · ${node.execution.effort} · ${node.execution.authority}`; + } + if (node.kind === "check") return `${node.measure ?? "test"} · ${node.capability ?? "deterministic"}`; + if (node.kind === "gate") return `${node.measure ?? "gate"}${node.target ? ` · ${node.target}` : ""}`; + if (node.kind === "terminal") return node.terminalState === "converged" ? "burn output" : "output"; + return node.role ?? node.kind; +} + +export function LoopLegend({ run, title = "Loop legend", symbols: overrides }: LoopLegendProps) { + if (!run) return null; + const symbols = loopSymbols(run.graph.nodes, overrides); + return
+ {run.graph.nodes.map((node) =>
+
{symbols.get(node.id)}
+
{node.id.toUpperCase()} · {description(node)}
+
)} +
; +} diff --git a/dashboard/src/components/LoopGraph/ascii-layout.test.ts b/dashboard/src/components/LoopGraph/ascii-layout.test.ts new file mode 100644 index 00000000..c216a804 --- /dev/null +++ b/dashboard/src/components/LoopGraph/ascii-layout.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { layoutAsciiGraph } from "./ascii-layout"; + +const graph = { + entry: "implement", + nodes: [ + { id: "implement", kind: "agent", role: "implementer", authority: "write" as const, + execution: { model: "gpt-5.3-codex-spark", effort: "low", authority: "write" as const } }, + { id: "verify", kind: "check", measure: "test" as const, capability: "repo-verify" }, + { id: "review", kind: "agent", role: "reviewer", authority: "read" as const }, + { id: "converged", kind: "gate", measure: "eval" as const, target: "approved" }, + { id: "completed", kind: "terminal" }, { id: "needs-human", kind: "terminal" }, + ], + edges: [ + { from: "implement", on: "complete", to: "verify" }, + { from: "verify", on: "pass", to: "review" }, + { from: "verify", on: "fail", to: "implement" }, + { from: "review", on: "approve", to: "converged" }, + { from: "review", on: "reject", to: "implement" }, + { from: "review", on: "escalate", to: "needs-human" }, + { from: "converged", on: "pass", to: "completed" }, + { from: "converged", on: "fail", to: "needs-human" }, + ], +}; + +test("lays out a responsive text-only graph with entry, outputs, decisions, and returns", () => { + const wide = layoutAsciiGraph(graph, "review", 110); + const narrow = layoutAsciiGraph(graph, "review", 52); + const wideText = wide.lines.join("\n"), narrowText = narrow.lines.join("\n"); + for (const token of ["INPUT", "+", "/", "\\", "▶", "▼", "reject", "escalate", + "IMPLEMENT", "VERIFY", "CONVERGED?", "COMPLETED"]) + assert.ok(wideText.includes(token), `missing ${token}`); + assert.ok(narrow.lines.length > wide.lines.length); + assert.ok(Math.max(...narrow.lines.map((line) => line.length)) <= 52); + assert.equal(narrow.current !== null, true); +}); diff --git a/dashboard/src/components/LoopGraph/ascii-layout.ts b/dashboard/src/components/LoopGraph/ascii-layout.ts new file mode 100644 index 00000000..586a4127 --- /dev/null +++ b/dashboard/src/components/LoopGraph/ascii-layout.ts @@ -0,0 +1,296 @@ +export type AsciiNode = { + id: string; + kind: string; + role?: string; + authority?: "write" | "read"; + capability?: string; + gateKind?: string; + measure?: "test" | "metric" | "eval" | "boolean"; + target?: string; + execution?: null | { model: string; effort: string; authority: "write" | "read" }; +}; +export type AsciiEdge = { from: string; on: string; to: string }; +export type AsciiGraph = { entry?: string; nodes: AsciiNode[]; edges: AsciiEdge[] }; +export type AsciiLayout = { lines: string[]; current: { x: number; y: number; width: number } | null }; + +const preferred = ["complete", "measured", "pass", "target-met", "approve", "success"]; + +function nodeLabel(node: AsciiNode) { + return `${node.id.toUpperCase()}${node.kind === "gate" ? "?" : ""}`; +} + +function contentLine(left: string, right: string, value: string, width: number) { + const content = value.slice(0, width - 4); + return `${left} ${content.padEnd(width - 4)} ${right}`; +} + +function nodeLines(node: AsciiNode, width: number) { + const label = nodeLabel(node); + if (node.kind === "gate") return [ + `/${"-".repeat(width - 2)}\\`, + contentLine("<", ">", label, width), + `\\${"-".repeat(width - 2)}/`, + ]; + if (node.kind === "terminal") return [ + `/${"-".repeat(width - 2)}\\`, + contentLine("|", "|", label, width), + `\\${"-".repeat(width - 2)}/`, + ]; + return [ + `+${"-".repeat(width - 2)}+`, + contentLine("|", "|", label, width), + `+${"-".repeat(width - 2)}+`, + ]; +} + +function primaryPath(graph: AsciiGraph) { + const entry = graph.entry ?? graph.nodes[0]?.id; + if (!entry) return []; + const result = [entry], seen = new Set(result); + let cursor = entry; + while (cursor) { + const candidates = graph.edges.filter((edge) => edge.from === cursor && !seen.has(edge.to)); + candidates.sort((left, right) => { + const rank = (edge: AsciiEdge) => { + const value = preferred.indexOf(edge.on); + return value < 0 ? preferred.length : value; + }; + return rank(left) - rank(right) || left.on.localeCompare(right.on); + }); + const edge = candidates[0]; + if (!edge) break; + result.push(edge.to); seen.add(edge.to); cursor = edge.to; + } + return result; +} + +function canvas(rows: number, columns: number) { + const cells = Array.from({ length: rows }, () => Array(columns).fill(" ")); + const lineMasks = Array.from({ length: rows }, () => Array(columns).fill(0)); + const glyphs: Record = { + 1: "│", 2: "─", 3: "└", 4: "│", 5: "│", 6: "┌", 7: "├", + 8: "─", 9: "┘", 10: "─", 11: "┴", 12: "┐", 13: "┤", 14: "┬", 15: "┼", + }; + const put = (x: number, y: number, value: string) => { + if (x < 0 || x >= columns || y < 0 || y >= rows) return; + cells[y][x] = value; + lineMasks[y][x] = 0; + }; + const connect = (x: number, y: number, mask: number) => { + if (x < 0 || x >= columns || y < 0 || y >= rows || !mask) return; + lineMasks[y][x] |= mask; + cells[y][x] = glyphs[lineMasks[y][x]]; + }; + const text = (x: number, y: number, value: string) => [...value].forEach((char, index) => put(x + index, y, char)); + const horizontal = (from: number, to: number, y: number) => { + const left = Math.min(from, to), right = Math.max(from, to); + for (let x = left; x <= right; x += 1) + connect(x, y, (x > left ? 8 : 0) | (x < right ? 2 : 0)); + }; + const vertical = (x: number, from: number, to: number) => { + const top = Math.min(from, to), bottom = Math.max(from, to); + for (let y = top; y <= bottom; y += 1) + connect(x, y, (y > top ? 1 : 0) | (y < bottom ? 4 : 0)); + }; + return { cells, put, text, horizontal, vertical }; +} + +function fanoutLayout(graph: AsciiGraph, currentNode: string, columns: number, width: number): AsciiLayout | null { + const planner = graph.nodes.find((node) => node.role === "planner" || node.role === "orchestrator"); + if (!planner) return null; + const branchEdges = graph.edges.filter((edge) => edge.from === planner.id); + if (branchEdges.length < 2 || width * branchEdges.length + 8 * (branchEdges.length - 1) > columns - 4) return null; + const outgoing = (id: string) => graph.edges.filter((edge) => edge.from === id); + const reachable = (start: string) => { + const result = new Map(), queue = [[start, 0] as const]; + while (queue.length) { + const [id, depth] = queue.shift()!; + if (result.has(id)) continue; + result.set(id, depth); + for (const edge of outgoing(id)) if (edge.to !== planner.id) queue.push([edge.to, depth + 1]); + } + return result; + }; + const maps = branchEdges.map((edge) => reachable(edge.to)); + const common = graph.nodes + .filter((node) => maps.every((map) => map.has(node.id))) + .sort((left, right) => maps.reduce((sum, map) => sum + map.get(left.id)!, 0) + - maps.reduce((sum, map) => sum + map.get(right.id)!, 0))[0]; + if (!common) return null; + const branchPaths = branchEdges.map((edge) => { + const path = [edge.to]; + while (path.at(-1) !== common.id) { + const next = outgoing(path.at(-1)!).filter((candidate) => candidate.to !== planner.id) + .sort((left, right) => (maps[0].get(left.to) ?? 999) - (maps[0].get(right.to) ?? 999))[0]; + if (!next || path.includes(next.to)) return []; + path.push(next.to); + } + return path.slice(0, -1); + }); + if (branchPaths.some((path) => !path.length)) return null; + const tail = [common.id]; + while (true) { + const next = outgoing(tail.at(-1)!).filter((edge) => !tail.includes(edge.to) && edge.to !== planner.id) + .sort((left, right) => preferred.indexOf(right.on) - preferred.indexOf(left.on))[0]; + if (!next) break; + tail.push(next.to); + } + const maxBranch = Math.max(...branchPaths.map((path) => path.length)); + const tailStart = 9 + maxBranch * 5; + const rows = tailStart + tail.length * 5 + graph.edges.length * 2 + 4; + const drawing = canvas(rows, columns), byId = new Map(graph.nodes.map((node) => [node.id, node])); + const positions = new Map(); + const branchLeft = 2; + const branchStep = (columns - 4 - width) / Math.max(1, branchPaths.length - 1); + const branchXs = branchPaths.map((_, index) => Math.round(branchLeft + index * branchStep)); + const totalWidth = branchXs.at(-1)! + width - branchLeft; + const plannerX = Math.floor((columns - width) / 2); + positions.set(planner.id, { x: plannerX, y: 2 }); + branchPaths.forEach((path, column) => path.forEach((id, row) => + positions.set(id, { x: branchXs[column], y: 8 + row * 5 }))); + tail.forEach((id, row) => positions.set(id, { x: plannerX, y: tailStart + row * 5 })); + for (const [id, position] of positions) { + const lines = nodeLines(byId.get(id)!, width); + drawing.text(position.x, position.y, lines[0]); drawing.text(position.x, position.y + 1, lines[1]); + drawing.text(position.x, position.y + 2, lines[2]); + } + const plannerCenter = plannerX + Math.floor(width / 2), splitY = 6; + const branchCenters = branchXs.map((x) => x + Math.floor(width / 2)); + drawing.vertical(plannerCenter, 5, splitY); drawing.horizontal(branchCenters[0], branchCenters.at(-1)!, splitY); + branchEdges.forEach((edge, index) => { + const center = branchCenters[index]; + drawing.vertical(center, splitY, 7); drawing.put(center, 7, "▼"); + drawing.text(center + 2, 7, edge.on); + }); + drawing.put(plannerCenter, splitY, "┴"); + branchCenters.forEach((center, index) => + drawing.put(center, splitY, index === 0 ? "┌" : index === branchCenters.length - 1 ? "┐" : "┬")); + branchPaths.forEach((path, column) => { + const center = branchCenters[column]; + for (let index = 1; index < path.length; index += 1) { + const from = positions.get(path[index - 1])!, to = positions.get(path[index])!; + const edge = graph.edges.find((candidate) => candidate.from === path[index - 1] && candidate.to === path[index])!; + drawing.vertical(center, from.y + 3, to.y - 1); drawing.put(center, to.y - 1, "▼"); + drawing.text(center + 2, from.y + 3, edge.on); + } + const last = path.at(-1)!, from = positions.get(last)!, join = positions.get(common.id)!; + const edge = graph.edges.find((candidate) => candidate.from === last && candidate.to === common.id)!; + const mergeY = join.y - 2; + drawing.vertical(center, from.y + 3, mergeY); drawing.horizontal(center, plannerCenter, mergeY); + drawing.put(center, mergeY, column === 0 ? "└" : column === branchPaths.length - 1 ? "┘" : "┴"); + drawing.text(center + 2, Math.min(mergeY, from.y + 3), edge.on); + }); + drawing.vertical(plannerCenter, tailStart - 2, tailStart - 1); drawing.put(plannerCenter, tailStart - 1, "▼"); + drawing.put(plannerCenter, tailStart - 2, "┬"); + for (let index = 1; index < tail.length; index += 1) { + const from = positions.get(tail[index - 1])!, to = positions.get(tail[index])!; + const edge = graph.edges.find((candidate) => candidate.from === tail[index - 1] && candidate.to === tail[index])!; + drawing.vertical(plannerCenter, from.y + 3, to.y - 1); drawing.put(plannerCenter, to.y - 1, "▼"); + drawing.text(plannerCenter + 2, from.y + 3, edge.on); + } + const feedback = graph.edges.filter((edge) => positions.has(edge.from) && edge.to === planner.id); + feedback.forEach((edge, index) => { + const from = positions.get(edge.from)!; + const plannerPosition = positions.get(planner.id)!; + const railX = Math.min(columns - 3, branchLeft + totalWidth + 4 + index * 2); + const sourceY = from.y + 1 + index; + drawing.horizontal(from.x + width, railX, sourceY); + drawing.vertical(railX, plannerPosition.y + 1, sourceY); + drawing.horizontal(plannerPosition.x + width, railX, plannerPosition.y + 1); + drawing.put(plannerPosition.x + width, plannerPosition.y + 1, "◀"); + drawing.text(from.x + width + 2, sourceY, edge.on); + }); + drawing.text(plannerX, 0, "INPUT"); + const active = positions.get(currentNode); + return { + lines: drawing.cells.map((line) => line.join("").trimEnd()) + .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)), + current: active ? { x: active.x, y: active.y, width } : null, + }; +} + +export function layoutAsciiGraph(graph: AsciiGraph, currentNode: string, availableCharacters: number): AsciiLayout { + if (!graph.nodes.length) return { lines: [], current: null }; + const columns = Math.max(36, Math.floor(availableCharacters)); + const desiredWidth = Math.max(...graph.nodes.map((node) => nodeLabel(node).length + 4)); + const width = Math.max(16, Math.min(24, columns - 8, desiredWidth)); + const fanout = fanoutLayout(graph, currentNode, columns, width); + if (fanout) return fanout; + const path = primaryPath(graph); + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const sideNodes = graph.nodes.filter((node) => !path.includes(node.id)); + const ordered = [...path, ...sideNodes.map((node) => node.id)]; + const usableColumns = Math.max(width, columns - 8); + const perRow = Math.max(1, Math.min(3, Math.floor((usableColumns + 4) / (width + 4)), ordered.length)); + const gap = perRow > 1 ? Math.floor((usableColumns - perRow * width) / (perRow - 1)) : 0; + const positions = new Map(); + ordered.forEach((id, index) => { + const row = Math.floor(index / perRow), offset = index % perRow; + const count = Math.min(perRow, ordered.length - row * perRow); + const visualColumn = row % 2 === 0 ? offset : count - offset - 1; + positions.set(id, { x: 2 + visualColumn * (width + gap), y: 2 + row * 7 }); + }); + const rows = Math.max(...[...positions.values()].map((position) => position.y)) + 5 + graph.edges.length * 2; + const drawing = canvas(rows, columns); + for (const [id, position] of positions) { + const rendered = nodeLines(byId.get(id)!, width); + drawing.text(position.x, position.y, rendered[0]); + drawing.text(position.x, position.y + 1, rendered[1]); + drawing.text(position.x, position.y + 2, rendered[2]); + } + const primaryKeys = new Set(); + for (let index = 0; index < path.length - 1; index += 1) { + const edge = graph.edges.find((candidate) => candidate.from === path[index] && candidate.to === path[index + 1]); + if (!edge) continue; + primaryKeys.add(`${edge.from}\0${edge.on}\0${edge.to}`); + const from = positions.get(edge.from)!, to = positions.get(edge.to)!; + if (from.y === to.y) { + const forward = from.x < to.x; + const start = forward ? from.x + width : from.x - 1; + const end = forward ? to.x - 1 : to.x + width; + drawing.horizontal(start, end, from.y + 1); + drawing.put(end, from.y + 1, forward ? "▶" : "◀"); + drawing.text(Math.min(start, end) + 1, from.y, edge.on.slice(0, Math.max(1, Math.abs(end - start) - 2))); + } else { + const fromCenter = from.x + Math.floor(width / 2), toCenter = to.x + Math.floor(width / 2); + const turnY = from.y + 5; + drawing.vertical(fromCenter, from.y + 3, turnY); + drawing.horizontal(fromCenter, toCenter, turnY); + drawing.vertical(toCenter, turnY, to.y - 1); + drawing.put(toCenter, to.y - 1, "▼"); + drawing.text(Math.min(fromCenter, toCenter) + 1, turnY, edge.on); + } + } + const alternate = graph.edges.filter((edge) => !primaryKeys.has(`${edge.from}\0${edge.on}\0${edge.to}`)); + const localRails = new Map(); + alternate.forEach((edge, index) => { + const from = positions.get(edge.from), to = positions.get(edge.to); + if (!from || !to) return; + const fromCenter = from.x + Math.floor(width / 2), toCenter = to.x + Math.floor(width / 2); + if (from.y === to.y) { + const offset = localRails.get(from.y) ?? 0; + localRails.set(from.y, offset + 1); + const railY = from.y + 4 + offset; + drawing.vertical(fromCenter, from.y + 3, railY); + drawing.horizontal(fromCenter, toCenter, railY); + drawing.vertical(toCenter, to.y + 3, railY); + drawing.put(toCenter, to.y + 3, "▲"); + drawing.text(Math.min(fromCenter, toCenter) + 1, railY, edge.on); + return; + } + const turnY = to.y - 1; + drawing.vertical(fromCenter, from.y + 3, turnY); + drawing.horizontal(fromCenter, toCenter, turnY); + drawing.vertical(toCenter, turnY, to.y - 1); + drawing.put(toCenter, to.y - 1, "▼"); + drawing.text(Math.min(fromCenter, toCenter) + 1, turnY, edge.on); + }); + const entryPosition = positions.get(graph.entry ?? graph.nodes[0].id); + if (entryPosition) drawing.text(entryPosition.x, 0, "INPUT"); + const active = positions.get(currentNode); + return { + lines: drawing.cells.map((line) => line.join("").trimEnd()) + .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)), + current: active ? { x: active.x, y: active.y, width } : null, + }; +} diff --git a/dashboard/src/components/LoopGraph/compact-layout.ts b/dashboard/src/components/LoopGraph/compact-layout.ts new file mode 100644 index 00000000..c9395484 --- /dev/null +++ b/dashboard/src/components/LoopGraph/compact-layout.ts @@ -0,0 +1,226 @@ +import type { LoopGraphProjection } from "./LoopGraph"; +import { loopPrimaryPath, loopSymbols } from "./loop-symbols"; + +type Position = { x: number; y: number }; + +function drawing(rows: number, columns: number) { + const cells = Array.from({ length: rows }, () => Array(columns).fill(" ")); + const lineMasks = Array.from({ length: rows }, () => Array(columns).fill(0)); + const glyphs: Record = { + 1: "│", 2: "─", 3: "└", 4: "│", 5: "│", 6: "┌", 7: "├", + 8: "─", 9: "┘", 10: "─", 11: "┴", 12: "┐", 13: "┤", 14: "┬", 15: "┼", + }; + const put = (x: number, y: number, value: string) => { + if (x >= 0 && x < columns && y >= 0 && y < rows) { + cells[y][x] = value; + lineMasks[y][x] = 0; + } + }; + const connect = (x: number, y: number, mask: number) => { + if (x < 0 || x >= columns || y < 0 || y >= rows || !mask) return; + lineMasks[y][x] |= mask; + cells[y][x] = glyphs[lineMasks[y][x]]; + }; + const horizontal = (from: number, to: number, y: number) => { + const left = Math.min(from, to), right = Math.max(from, to); + for (let x = left; x <= right; x += 1) + connect(x, y, (x > left ? 8 : 0) | (x < right ? 2 : 0)); + }; + const vertical = (x: number, from: number, to: number) => { + const top = Math.min(from, to), bottom = Math.max(from, to); + for (let y = top; y <= bottom; y += 1) + connect(x, y, (y > top ? 1 : 0) | (y < bottom ? 4 : 0)); + }; + const text = (x: number, y: number, value: string) => + [...value].forEach((character, index) => put(x + index, y, character)); + return { cells, put, text, horizontal, vertical }; +} + +type CompactOptions = { showLabels?: boolean; symbols?: Record }; + +function fanoutCompact(run: LoopGraphProjection, options: CompactOptions) { + const planner = run.graph.nodes.find((node) => node.role === "planner" || node.role === "orchestrator"); + if (!planner) return null; + const outgoing = (id: string) => run.graph.edges.filter((edge) => edge.from === id); + const starts = outgoing(planner.id); + if (starts.length < 2) return null; + const reachable = (start: string) => { + const seen = new Set(), queue = [start]; + while (queue.length) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + outgoing(id).filter((edge) => edge.to !== planner.id).forEach((edge) => queue.push(edge.to)); + } + return seen; + }; + const maps = starts.map((edge) => reachable(edge.to)); + const common = run.graph.nodes.find((node) => maps.every((map) => map.has(node.id))); + if (!common) return null; + const paths = starts.map((edge) => { + const path = [edge.to]; + while (path.at(-1) !== common.id) { + const next = outgoing(path.at(-1)!).find((candidate) => candidate.to !== planner.id); + if (!next || path.includes(next.to)) return []; + path.push(next.to); + } + return path.slice(0, -1); + }); + if (paths.some((path) => !path.length)) return null; + const tail = [common.id]; + while (true) { + const next = outgoing(tail.at(-1)!).find((edge) => !tail.includes(edge.to) && edge.to !== planner.id); + if (!next) break; + tail.push(next.to); + } + const symbols = loopSymbols(run.graph.nodes, options.symbols); + const branchStep = options.showLabels ? 22 : 14; + const centers = paths.map((_, index) => index * branchStep); + const center = Math.round((centers[0] + centers.at(-1)!) / 2); + const maxDepth = Math.max(...paths.map((path) => path.length)); + const commonY = 5 + maxDepth * 3; + const positions = new Map([[planner.id, { x: center, y: 0 }]]); + paths.forEach((path, column) => path.forEach((id, row) => + positions.set(id, { x: centers[column], y: 4 + row * 3 }))); + tail.forEach((id, row) => positions.set(id, { x: center, y: commonY + row * 3 })); + const feedback = run.graph.edges.filter((edge) => edge.to === planner.id && positions.has(edge.from)); + const labelClearance = options.showLabels + ? Math.max(...run.graph.edges.map((edge) => edge.on.length), 0) + 5 + : 4; + const columns = centers.at(-1)! + labelClearance + 2 + feedback.length * 2; + const rows = commonY + tail.length * 3 + 2; + const graph = drawing(rows, columns); + for (const [id, position] of positions) graph.text(position.x, position.y, symbols.get(id)!); + graph.vertical(center, 1, 2); + graph.horizontal(centers[0], centers.at(-1)!, 2); + centers.forEach((branchCenter) => { + graph.vertical(branchCenter, 2, 3); + graph.put(branchCenter, 3, "▼"); + }); + if (options.showLabels) starts.forEach((edge, index) => + graph.text(centers[index] + 2, 3, edge.on)); + paths.forEach((path, column) => { + const branchCenter = centers[column]; + for (let index = 1; index < path.length; index += 1) { + const from = positions.get(path[index - 1])!, to = positions.get(path[index])!; + graph.vertical(branchCenter, from.y + 1, to.y - 1); + graph.put(branchCenter, to.y - 1, "▼"); + if (options.showLabels) { + const edge = run.graph.edges.find((candidate) => + candidate.from === path[index - 1] && candidate.to === path[index]); + if (edge) graph.text(branchCenter + 2, from.y + 1, edge.on); + } + } + const from = positions.get(path.at(-1)!)!, mergeY = commonY - 2; + graph.vertical(branchCenter, from.y + 1, mergeY); + graph.horizontal(branchCenter, center, mergeY); + if (options.showLabels) { + const edge = run.graph.edges.find((candidate) => + candidate.from === path.at(-1) && candidate.to === common.id); + if (edge) graph.text(branchCenter + 2, from.y + 1, edge.on); + } + }); + graph.vertical(center, commonY - 2, commonY - 1); + graph.put(center, commonY - 1, "▼"); + for (let index = 1; index < tail.length; index += 1) { + const from = positions.get(tail[index - 1])!, to = positions.get(tail[index])!; + graph.vertical(center, from.y + 1, to.y - 1); + graph.put(center, to.y - 1, "▼"); + if (options.showLabels) { + const edge = run.graph.edges.find((candidate) => + candidate.from === tail[index - 1] && candidate.to === tail[index]); + if (edge) graph.text(center + 2, from.y + 1, edge.on); + } + } + feedback.forEach((edge, index) => { + const from = positions.get(edge.from)!, railX = centers.at(-1)! + labelClearance + index * 2; + graph.horizontal(from.x + 2, railX, from.y); + graph.vertical(railX, 0, from.y); + graph.horizontal(center + 2, railX, 0); + graph.put(center + 2, 0, "◀"); + if (options.showLabels) graph.text(from.x + 2, from.y, edge.on); + }); + return { + lines: graph.cells.map((line) => line.join("").trimEnd()) + .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)), + positions, + }; +} + +export function layoutCompactLoop(run: LoopGraphProjection, options: CompactOptions = {}) { + const fanout = fanoutCompact(run, options); + if (fanout) return fanout; + const path = loopPrimaryPath(run.graph); + const symbols = loopSymbols(run.graph.nodes, options.symbols); + const primaryEdges = path.slice(0, -1).map((from, index) => + run.graph.edges.find((edge) => edge.from === from && edge.to === path[index + 1])!); + const positions = new Map(); + let cursor = 0; + path.forEach((id, index) => { + positions.set(id, { x: cursor, y: 0 }); + const edge = primaryEdges[index]; + if (edge) cursor += options.showLabels ? edge.on.length + 7 : 6; + }); + const primary = new Set(path.slice(0, -1).map((from, index) => `${from}\0${path[index + 1]}`)); + const alternate = run.graph.edges.filter((edge) => !primary.has(`${edge.from}\0${edge.to}`)); + const backwards = alternate.filter((edge) => { + const from = positions.get(edge.from), to = positions.get(edge.to); + return from && to && to.x < from.x; + }); + const branches = alternate.filter((edge) => !backwards.includes(edge)); + const branchStart = 2 + backwards.length * 2; + const columns = Math.max(1, cursor + 2, ...branches.map((edge) => + (positions.get(edge.from)?.x ?? 0) + (options.showLabels ? edge.on.length + 8 : 8))); + const rows = Math.max(1, branchStart + branches.length * 3); + const graph = drawing(rows, columns); + + path.forEach((id, index) => { + const position = positions.get(id)!; + graph.text(position.x, 0, symbols.get(id)!); + if (index === 0) return; + const previous = positions.get(path[index - 1])!; + graph.horizontal(previous.x + 2, position.x - 2, 0); + graph.put(position.x - 2, 0, "▶"); + if (options.showLabels) graph.text(previous.x + 3, 0, primaryEdges[index - 1].on); + }); + + backwards.forEach((edge, index) => { + const from = positions.get(edge.from)!, to = positions.get(edge.to)!; + const rail = 2 + index * 2; + graph.vertical(from.x, 1, rail); + graph.horizontal(to.x, from.x, rail); + graph.vertical(to.x, 1, rail); + graph.put(to.x, 1, "▲"); + if (options.showLabels) graph.text(to.x + 2, rail, edge.on); + }); + + branches.forEach((edge, index) => { + const from = positions.get(edge.from); + if (!from) return; + const existingTarget = positions.get(edge.to); + if (existingTarget) { + const rail = Math.max(from.y + 1, branchStart + index * 2); + graph.vertical(from.x, from.y + 1, rail); + graph.horizontal(from.x, existingTarget.x, rail); + graph.vertical(existingTarget.x, 1, rail); + graph.put(existingTarget.x, 1, "▲"); + if (options.showLabels) graph.text(Math.min(from.x, existingTarget.x) + 2, rail, edge.on); + return; + } + const y = branchStart + index * 3 + 2; + const distance = options.showLabels ? edge.on.length + 5 : 6; + const x = Math.min(columns - 1, from.x + distance); + positions.set(edge.to, { x, y: y - 1 }); + graph.vertical(from.x, 1, y - 1); + graph.horizontal(from.x, x - 2, y - 1); + graph.put(x - 2, y - 1, "▶"); + graph.text(x, y - 1, symbols.get(edge.to)!); + if (options.showLabels) graph.text(from.x + 2, y - 1, edge.on); + }); + + return { + lines: graph.cells.map((line) => line.join("").trimEnd()) + .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)), + positions, + }; +} diff --git a/dashboard/src/components/LoopGraph/index.ts b/dashboard/src/components/LoopGraph/index.ts new file mode 100644 index 00000000..9845204c --- /dev/null +++ b/dashboard/src/components/LoopGraph/index.ts @@ -0,0 +1,6 @@ +export { LoopGraph } from "./LoopGraph"; +export { itemTopologyProjection, LoopCompact } from "./LoopCompact"; +export { LoopLegend } from "./LoopLegend"; +export type { LoopGraphEdge, LoopGraphNode, LoopGraphProjection, LoopGraphProps, LoopGraphTransition } from "./LoopGraph"; +export type { LoopCompactProps } from "./LoopCompact"; +export type { LoopLegendProps } from "./LoopLegend"; diff --git a/dashboard/src/components/LoopGraph/loop-symbols.ts b/dashboard/src/components/LoopGraph/loop-symbols.ts new file mode 100644 index 00000000..a1b6d738 --- /dev/null +++ b/dashboard/src/components/LoopGraph/loop-symbols.ts @@ -0,0 +1,49 @@ +import type { LoopGraphNode, LoopGraphProjection } from "./LoopGraph"; + +const preferredOutcomes = ["complete", "measured", "pass", "target-met", "approve", "success"]; + +export function loopSymbols(nodes: LoopGraphNode[], overrides: Record = {}) { + const totals = new Map(); + const seen = new Map(); + const bases = nodes.map((node) => { + if (/^implement/u.test(node.id)) return "I"; + if (/^(verify|validate|measure)/u.test(node.id)) return "V"; + if (/^review/u.test(node.id)) return "R"; + if (/^(converged|.*-gate$)/u.test(node.id) || node.kind === "gate") return "G"; + if (/^completed/u.test(node.id)) return "C"; + if (/^plan/u.test(node.id)) return "P"; + if (/^combine/u.test(node.id)) return "M"; + if (/needs-human/u.test(node.id)) return "H"; + return node.id.match(/[a-z0-9]/iu)?.[0]?.toUpperCase() ?? "N"; + }); + for (const base of bases) totals.set(base, (totals.get(base) ?? 0) + 1); + return new Map(nodes.map((node, index) => { + const base = bases[index]; + const occurrence = (seen.get(base) ?? 0) + 1; + seen.set(base, occurrence); + return [node.id, overrides[node.id] ?? (totals.get(base)! > 1 ? `${base}${occurrence}` : base)]; + })); +} + +export function loopPrimaryPath(graph: LoopGraphProjection["graph"]) { + const entry = graph.entry ?? graph.nodes[0]?.id; + if (!entry) return []; + const path = [entry], seen = new Set(path); + let current = entry; + while (current) { + const candidates = graph.edges.filter((edge) => edge.from === current && !seen.has(edge.to)); + candidates.sort((left, right) => { + const rank = (outcome: string) => { + const index = preferredOutcomes.indexOf(outcome); + return index < 0 ? preferredOutcomes.length : index; + }; + return rank(left.on) - rank(right.on) || left.on.localeCompare(right.on); + }); + const next = candidates[0]; + if (!next) break; + path.push(next.to); + seen.add(next.to); + current = next.to; + } + return path; +} diff --git a/dashboard/src/hooks/dashboard-data.mjs b/dashboard/src/hooks/dashboard-data.mjs index ba3220fa..3f0ade68 100644 --- a/dashboard/src/hooks/dashboard-data.mjs +++ b/dashboard/src/hooks/dashboard-data.mjs @@ -79,9 +79,10 @@ export function receiveLoopProjection(response, json) { export function dashboardLoopProjectionSnapshotConfig(enabled, selected) { const query = selectedQuery(selected); + const subjectId = selected?.id && selected?.item ? `item:${selected.id}#${selected.item}` : null; return { transport: "snapshot", enabled: enabled && Boolean(selected), repoKey: selected?.repoKey ?? null, - ovenId: "checklist", subjectId: null, query: `loop/${query}`, + ovenId: "checklist", subjectId, query: `loop/${query}`, makeUrl: () => `/api/loop-projection?${query}`, receive: receiveLoopProjection, fallbackError: "Could not load Loop projection.", initialData: null, events: [Object.freeze({ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" })], deps: [enabled, query], diff --git a/dashboard/src/hooks/dashboard-data.test.mjs b/dashboard/src/hooks/dashboard-data.test.mjs index 3456b633..d1b6b3ea 100644 --- a/dashboard/src/hooks/dashboard-data.test.mjs +++ b/dashboard/src/hooks/dashboard-data.test.mjs @@ -138,9 +138,10 @@ test("landing projections share matching invalidations and the manual-write fall test("loop projection uses its dedicated snapshot URL and coalesces conditional event/reset refreshes", async () => { const timers = fakeTimers(), sources = [], calls = [], states = []; - const selected = { repoKey: "aaaaaaaaaaaa", id: "260722-001" }; + const selected = { repoKey: "aaaaaaaaaaaa", id: "260722-001", item: "M7" }; const config = dashboardLoopProjectionSnapshotConfig(true, selected); - assert.equal(config.makeUrl(), "/api/loop-projection?repoKey=aaaaaaaaaaaa&id=260722-001"); + assert.equal(config.makeUrl(), "/api/loop-projection?repoKey=aaaaaaaaaaaa&id=260722-001&item=M7"); + assert.equal(config.subjectId, "item:260722-001#M7"); assert.deepEqual(config.events, [{ ovenId: "checklist", kind: "loop-projection-changed", phase: "complete" }]); const client = createOvenSnapshotClient({ timers, focusTarget: null, diff --git a/dashboard/src/hooks/useDashboardData.ts b/dashboard/src/hooks/useDashboardData.ts index 4e0c066c..745b5093 100644 --- a/dashboard/src/hooks/useDashboardData.ts +++ b/dashboard/src/hooks/useDashboardData.ts @@ -14,7 +14,9 @@ export function useDashboardData({ section, selected }: { section: string; selec const enabled = section === "burnlists"; const projectsState = useOvenLiveData(dashboardProjectsSnapshotConfig(enabled)); const progressState = useOvenLiveData(dashboardProgressSnapshotConfig(enabled, selected)); - const loopState = useOvenLiveData(dashboardLoopProjectionSnapshotConfig(enabled, selected)); + const currentItem = progressState.data && "active" in progressState.data ? progressState.data.active[0]?.id : undefined; + const loopSelection = selected && currentItem ? { ...selected, item: currentItem } : selected; + const loopState = useOvenLiveData(dashboardLoopProjectionSnapshotConfig(enabled, loopSelection)); const loopDiagnostic = loopState.error.includes("retaining the last verified projection") ? "corrupt" : loopState.stale || loopState.error ? "stale" : undefined; const loopRun = loopState.data && loopDiagnostic ? { ...loopState.data, diagnostic: loopDiagnostic } : loopState.data; diff --git a/dashboard/src/lib/checklist-adapter.ts b/dashboard/src/lib/checklist-adapter.ts index fe7bbac5..48c114b9 100644 --- a/dashboard/src/lib/checklist-adapter.ts +++ b/dashboard/src/lib/checklist-adapter.ts @@ -5,6 +5,7 @@ export type EventDetailField = { label: string; values: string[] }; export type ChecklistOvenPayload = { raw: ChecklistProgressData; + items: Array; current: { value: string; title: string }; progress: { done: number; total: number; percent: number; title: string }; durations: { elapsed: string; pace: string; timeLeft: string }; @@ -96,6 +97,10 @@ export function adaptChecklist(data: ChecklistProgressData): ChecklistOvenPayloa const current = data.active[0]; return { raw: data, + items: data.active.map((item) => ({ + ...item, + loopRun: data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null, + })), current: { value: current ? `${current.id} · Active` : "Complete", title: current?.title ?? "No active task" }, progress: { done: data.done, total: data.total, percent: data.percent, title: `${data.done} of ${data.total} tasks complete` }, durations: { elapsed: formatDuration(durations.elapsed), pace: formatDuration(durations.pace), timeLeft: formatDuration(durations.timeLeft) }, diff --git a/dashboard/src/lib/hrefs.ts b/dashboard/src/lib/hrefs.ts index 5bfa5326..58e8720e 100644 --- a/dashboard/src/lib/hrefs.ts +++ b/dashboard/src/lib/hrefs.ts @@ -2,7 +2,14 @@ import { burnlistHref as buildBurnlistHref, parseRoute } from "./route-model.mjs import type { Burnlist, Filter, SelectedBurnlist } from "./types"; function route() { - return parseRoute({ pathname: window.location.pathname, search: window.location.search }); + return parseRoute(typeof window === "undefined" + ? { pathname: "/", search: "" } + : { pathname: window.location.pathname, search: window.location.search }); +} +function itemFromHash() { + if (typeof window === "undefined") return undefined; + if (window.location.hash.length <= 1) return undefined; + try { return decodeURIComponent(window.location.hash.slice(1)); } catch { return undefined; } } export function currentSection() { @@ -17,7 +24,7 @@ export function customOvenSelection(): { id: string; repoKey: string | null; bur export function ovenExplainerSelection(): { ovenId: string; repoKey: string | null } | null { const current = route(); return current.section === "oven-explainer" - ? { ovenId: current.ovenId, repoKey: new URLSearchParams(window.location.search).get("repoKey") } + ? { ovenId: current.ovenId, repoKey: new URLSearchParams(typeof window === "undefined" ? "" : window.location.search).get("repoKey") } : null; } @@ -44,13 +51,14 @@ export function streamingDiffSelection() { export function selectedBurnlist(): SelectedBurnlist | null { const current = route(); - if (current.plan) return { plan: current.plan }; - if (current.repoKey && current.burnlistId) return { repoKey: current.repoKey, id: current.burnlistId }; - return current.repo && current.burnlistId ? { repo: current.repo, id: current.burnlistId } : null; + const item = itemFromHash(); + if (current.plan) return { plan: current.plan, ...(item ? { item } : {}) }; + if (current.repoKey && current.burnlistId) return { repoKey: current.repoKey, id: current.burnlistId, ...(item ? { item } : {}) }; + return current.repo && current.burnlistId ? { repo: current.repo, id: current.burnlistId, ...(item ? { item } : {}) } : null; } export function filterFromUrl(filters: Array<{ value: Filter }>): Filter { - const value = new URLSearchParams(window.location.search).get("filter") as Filter | null; + const value = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search).get("filter") as Filter | null; return filters.some((filter) => filter.value === value) ? value! : "active"; } diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index d42ba877..2fa4b91c 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -1,6 +1,17 @@ export type Filter = "active" | "draft" | "ready" | "complete" | "all"; -export type ChecklistItem = { id: string; title: string; fields: Record }; +export type ChecklistItem = { + id: string; + title: string; + fields: Record; + loop?: null | { + selector: string; + assignmentId: string; + executionRevision: string; + packageRevision: string; + graph?: LoopRunProjection["graph"] | null; + }; +}; export type CompletedItem = { id: string; title: string; completedAt: string; detail: string }; export type Warning = { severity: "error" | "warning"; message: string }; export type HistoryPoint = { time: string; done: number; remaining: number; total: number; percent: number }; @@ -29,7 +40,23 @@ export type LoopRunProjection = { latestReviewer?: null | { summary: string; at: number; candidateId: string | null }; graph: { entry: string; - nodes: Array<{ id: string; kind: string }>; + nodes: Array<{ + id: string; + kind: string; + role?: string; + authority?: "write" | "read"; + capability?: string; + gateKind?: string; + measure?: "test" | "metric" | "eval" | "boolean"; + target?: string; + terminalState?: string; + execution?: null | { + profileId: string; + model: string; + effort: string; + authority: "write" | "read"; + }; + }>; edges: Array<{ from: string; on: string; to: string }>; }; transitions: Array<{ sequence: number; from: string; outcome: string; to: string }>; @@ -42,6 +69,7 @@ export type ChecklistProgressData = { title: string; repo: string; planLabel: string; + selectedItemId?: string | null; total: number; done: number; remaining: number; @@ -141,5 +169,5 @@ export type StreamingDiffFile = { path: string; kind: StreamingDiffFileKind; dif export type StreamingDiffCard = { revId: string; toolUseId: string; ts: string; status: "captured" | "partial"; partialReason?: string; files: StreamingDiffFile[] }; export type StreamingDiffFeed = { identity: StreamingDiffIdentity; updatedAt: string | null; href: string; repoLabel?: string }; -export type SelectedBurnlist = { repo?: string; repoKey?: string; id?: string; plan?: string }; +export type SelectedBurnlist = { repo?: string; repoKey?: string; id?: string; plan?: string; item?: string }; export type ProgressData = ChecklistProgressData; diff --git a/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.css b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.css new file mode 100644 index 00000000..cafcf5ba --- /dev/null +++ b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.css @@ -0,0 +1,79 @@ +.checklist-current { + position: relative; + z-index: 1; + container-type: inline-size; + min-width: 0; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.checklist-current::after { + position: absolute; + top: 28px; + bottom: 0; + left: 0; + border-left: 1px solid rgba(163, 172, 183, .1); + content: ""; + pointer-events: none; +} + +.checklist-current__empty { + display: grid; + height: 100%; + place-items: center; + color: rgba(168, 168, 168, .58); + font: 12px/1.3 var(--dashboard-font); +} + +.checklist-current__header { + display: flex; + height: 20px; + min-height: 20px; + margin-bottom: 8px; + padding: 0; + align-items: baseline; + justify-content: flex-end; +} + +.checklist-current__visual { + display: flex; + height: calc(100% - 28px); + width: 100%; + overflow: hidden; + flex-direction: column; +} + +.checklist-current .loop-compact { + display: flex; + width: 100%; + min-height: 0; + margin: 0; + padding: 12px 10px 4px; + overflow: hidden; + flex: 1 1 auto; + background: transparent; + color: rgba(220, 225, 232, .82); + font-size: clamp(14px, 3.5cqw, 20px); + line-height: 1.25; + align-items: center; + justify-content: center; +} + +.checklist-current__legend { + display: flex; + min-height: 24px; + padding: 0 12px 3px; + color: rgba(168, 168, 168, .5); + font: 12px/1 var(--dashboard-font); + align-items: flex-end; + justify-content: space-between; + white-space: nowrap; +} + +.checklist-current__legend b { + color: rgba(220, 225, 232, .82); + font-weight: 600; +} diff --git a/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.test.ts b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.test.ts new file mode 100644 index 00000000..e4ba4ade --- /dev/null +++ b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ChecklistCurrent } from "./ChecklistCurrent"; + +const item = (id: string, loop: unknown = null) => ({ id, title: `Item ${id}`, fields: {}, loop }); + +test("keeps the canonical current item's Loop while another item is inspected", () => { + const run: any = { + itemRef: "item:list#B", loopId: "review", state: "prepared", currentNode: "implement", attempt: 0, cycle: 0, + graph: { + nodes: [ + { id: "implement", kind: "agent" }, + { id: "verify", kind: "check" }, + { id: "review", kind: "agent" }, + { id: "completed", kind: "terminal", terminalState: "converged" }, + ], + edges: [ + { from: "implement", on: "complete", to: "verify" }, + { from: "verify", on: "pass", to: "review" }, + { from: "review", on: "approve", to: "completed" }, + ], + }, + }; + const data: any = { active: [item("B", { selector: "loop:builtin:review" }), item("A")], selectedItemId: "A", loopRun: run }; + const markup = renderToStaticMarkup(createElement(ChecklistCurrent, { data })); + assert.match(markup, /aria-label="Loop for item B"/u); + assert.match(markup, /loop:builtin:review/u); + assert.match(markup, /loop-compact/u); + assert.match(markup, />R<\/b> Review/u); + assert.doesNotMatch(markup, /Item A/u); +}); + +test("distinguishes direct and assigned-but-unstarted items", () => { + const direct = renderToStaticMarkup(createElement(ChecklistCurrent, { data: { active: [item("A")], selectedItemId: "A", loopRun: null } as any })); + const assigned = renderToStaticMarkup(createElement(ChecklistCurrent, { data: { active: [item("B", { selector: "loop:builtin:review" })], selectedItemId: "B", loopRun: null } as any })); + assert.equal(direct, ""); + assert.match(assigned, /Assigned · not started/u); +} +); diff --git a/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.tsx b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.tsx new file mode 100644 index 00000000..fc4d67ab --- /dev/null +++ b/dashboard/src/oven/ChecklistCurrent/ChecklistCurrent.tsx @@ -0,0 +1,43 @@ +import type { ChecklistProgressData } from "@lib"; +import { itemTopologyProjection, LoopCompact } from "@/components/LoopGraph"; +import { loopPrimaryPath, loopSymbols } from "@/components/LoopGraph/loop-symbols"; +import "./ChecklistCurrent.css"; + +function nodeLabel(id: string, kind: string, terminalState?: string) { + if (id === "start") return "Start"; + if (kind === "terminal" && terminalState === "converged") return "Burn"; + return id.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); +} + +export function ChecklistCurrent({ data }: { data: ChecklistProgressData }) { + const item = data.active[0]; + if (!item?.loop) return null; + const run = data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null; + const topology = run ? itemTopologyProjection(run) : null; + const symbols = topology ? loopSymbols(topology.graph.nodes, { + start: "S", + ...Object.fromEntries(topology.graph.nodes + .filter((node) => node.kind === "terminal" && node.terminalState === "converged") + .map((node) => [node.id, "B"])), + }) : null; + const primaryIds = topology ? loopPrimaryPath(topology.graph) : []; + const orderedNodes = topology ? [ + ...primaryIds.map((id) => topology.graph.nodes.find((node) => node.id === id)).filter((node) => node !== undefined), + ...topology.graph.nodes.filter((node) => !primaryIds.includes(node.id)), + ] : []; + return
+
+ Loop {item.id} {run?.currentNode ?? (item.loop ? "Ready" : "Direct")} +
+ {run + ?
+ +
+ {orderedNodes.map((node) => + {symbols?.get(node.id)} {nodeLabel(node.id, node.kind, node.terminalState)} + )} +
+
+ : item.loop ? Assigned · not started : null} +
; +} diff --git a/dashboard/src/oven/ChecklistCurrent/index.ts b/dashboard/src/oven/ChecklistCurrent/index.ts new file mode 100644 index 00000000..d275e71f --- /dev/null +++ b/dashboard/src/oven/ChecklistCurrent/index.ts @@ -0,0 +1 @@ +export { ChecklistCurrent } from "./ChecklistCurrent"; diff --git a/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx b/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx index 025bd90c..971b751c 100644 --- a/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx +++ b/dashboard/src/oven/ChecklistEventCards/ChecklistEventCards.tsx @@ -1,6 +1,6 @@ import type { ChecklistProgressData } from "@lib"; -import { EventCardList } from "@/components/ChecklistDashboard/ChecklistDashboard"; +import { ChecklistWorkspace } from "../ChecklistWorkspace"; export function ChecklistEventCards({ data }: { data: ChecklistProgressData }) { - return ; + return ; } diff --git a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts index 7bc639e2..e6f8729d 100644 --- a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts +++ b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts @@ -3,8 +3,9 @@ import { test } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { compileOven } from "../../../../src/ovens/dsl/oven-compile.mjs"; -import { EventCardList, ProgressLedger, ProgressPanel } from "@/components/ChecklistDashboard/ChecklistDashboard"; +import { ProgressLedger, ProgressPanel } from "@/components/ChecklistDashboard/ChecklistDashboard"; import { checklistFixture } from "@/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; +import { ChecklistWorkspace } from "../ChecklistWorkspace"; import { assertDomEquivalent } from "../test-support/dom-normalize"; import { OvenNode } from "../runtime/OvenNode"; import { initOvenState, type OvenIr } from "../runtime/oven-reducer"; @@ -19,7 +20,7 @@ function renderWidget(kind: string) { test("checklist widget adapters preserve the exported dashboard subregions", () => { assertDomEquivalent(renderWidget("checklist-burn-panel"), renderToStaticMarkup(createElement(ProgressPanel, { data: checklistFixture }))); assertDomEquivalent(renderWidget("checklist-ledger"), renderToStaticMarkup(createElement(ProgressLedger, { data: checklistFixture }))); - assertDomEquivalent(renderWidget("checklist-event-cards"), renderToStaticMarkup(createElement(EventCardList, { data: checklistFixture }))); + assertDomEquivalent(renderWidget("checklist-event-cards"), renderToStaticMarkup(createElement(ChecklistWorkspace, { data: checklistFixture }))); }); test("box lowering preserves element, class, id, text, and children", () => { diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css new file mode 100644 index 00000000..f428b74e --- /dev/null +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css @@ -0,0 +1,110 @@ +.checklist-workspace { + display: grid; + width: 100%; + min-height: 330px; + margin-top: 12px; + border-top: 1px solid rgba(168, 168, 168, .15); + border-bottom: 1px solid rgba(168, 168, 168, .1); + grid-template-columns: minmax(220px, 26%) minmax(250px, 30%) minmax(360px, 44%); +} + +.checklist-workspace__column { min-width: 0; background: #111; } +.checklist-workspace__column + .checklist-workspace__column { border-left: 1px solid rgba(163, 172, 183, .1); } +.checklist-workspace__heading { + display: flex; + height: 36px; + padding: 0 12px; + border-bottom: 1px solid rgba(163, 172, 183, .1); + color: rgba(168, 168, 168, .62); + font: 12px/1 var(--dashboard-font); + align-items: center; + justify-content: space-between; +} + +.checklist-workspace__event-list, +.checklist-workspace__item-list { display: block; max-height: 390px; overflow: auto; } +.checklist-workspace__event { + display: grid; + min-height: 42px; + padding: 0 12px; + border-bottom: 1px solid rgba(163, 172, 183, .07); + grid-template-columns: 3ch minmax(0, 1fr) max-content; + align-items: center; + gap: 8px; +} +.checklist-workspace__event-id { color: rgba(97, 211, 148, .78); font-size: 12px; } +.checklist-workspace__event-title, +.checklist-workspace__item-copy span { + min-width: 0; + overflow: hidden; + color: rgba(220, 225, 232, .76); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.checklist-workspace__meta, +.checklist-workspace__loop-label { color: rgba(168, 168, 168, .42); font-size: 10px; white-space: nowrap; } + +.checklist-workspace__item { + display: grid; + min-height: 48px; + padding: 0 12px; + border-bottom: 1px solid rgba(163, 172, 183, .07); + color: inherit; + grid-template-columns: 3ch minmax(0, 1fr) max-content; + align-items: center; + gap: 8px; + text-decoration: none; +} +.checklist-workspace__item:hover, +.checklist-workspace__item:focus-visible { background: rgba(255, 255, 255, .025); outline: none; } +.checklist-workspace__item.is-selected { background: rgba(90, 162, 255, .07); box-shadow: inset 2px 0 rgba(90, 162, 255, .7); } +.checklist-workspace__item-marker { color: rgba(168, 168, 168, .42); font-size: 11px; text-align: center; } +.checklist-workspace__item:first-child .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item-copy { display: grid; min-width: 0; gap: 3px; } +.checklist-workspace__item-copy b { color: rgba(232, 232, 232, .86); font-size: 12px; font-weight: 500; } + +.checklist-workspace__detail-body { padding: 14px 16px 12px; } +.checklist-workspace__detail-title { display: grid; grid-template-columns: 3ch minmax(0, 1fr); gap: 8px; } +.checklist-workspace__detail-title > span { color: var(--status-green); font-size: 12px; } +.checklist-workspace__detail-title h2 { margin: 0; color: rgba(232, 232, 232, .9); font: 14px/1.3 var(--dashboard-font); } +.checklist-workspace__fields { display: grid; margin: 12px 0 0; gap: 8px; } +.checklist-workspace__fields > div { display: grid; grid-template-columns: 8ch minmax(0, 1fr); gap: 10px; } +.checklist-workspace__fields dt { color: rgba(168, 168, 168, .5); font-size: 11px; } +.checklist-workspace__fields dd { margin: 0; color: rgba(210, 216, 224, .7); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; } +.checklist-workspace__detail-loop { margin-top: 14px; padding-top: 10px; border-top: 1px solid rgba(163, 172, 183, .08); } +.checklist-workspace__direct { margin-top: 14px; padding-top: 10px; border-top: 1px solid rgba(163, 172, 183, .08); color: rgba(168, 168, 168, .5); font-size: 11px; } +.checklist-workspace__loop-head { display: flex; color: rgba(168, 168, 168, .5); font-size: 10px; justify-content: space-between; } +.checklist-workspace__detail .loop-compact { + display: flex; + min-height: 88px; + margin: 0; + color: rgba(220, 225, 232, .8); + font-size: 12px; + align-items: center; + justify-content: center; +} +.checklist-workspace__detail .loop-legend { + display: grid; + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid rgba(163, 172, 183, .07); + color: rgba(168, 168, 168, .55); + font-size: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px 12px; +} +.checklist-workspace__detail .loop-legend__row { min-width: 0; gap: 5px; grid-template-columns: 2ch minmax(0, 1fr); } +.checklist-workspace__detail .loop-legend dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.checklist-workspace__empty { margin: 0; padding: 16px; color: rgba(168, 168, 168, .5); font-size: 12px; } + +@media (max-width: 900px) { + .checklist-workspace { grid-template-columns: minmax(220px, 38%) minmax(0, 62%); } + .checklist-workspace__events { grid-column: 1 / -1; grid-row: 2; border-top: 1px solid rgba(163, 172, 183, .1); } + .checklist-workspace__events .checklist-workspace__event-list { max-height: 210px; } +} + +@media (max-width: 620px) { + .checklist-workspace { display: block; } + .checklist-workspace__column + .checklist-workspace__column { border-top: 1px solid rgba(163, 172, 183, .1); border-left: 0; } +} diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx new file mode 100644 index 00000000..a0895c8e --- /dev/null +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx @@ -0,0 +1,106 @@ +import type { ChecklistItem, ChecklistProgressData, LoopRunProjection } from "@lib"; +import { compactAge, eventRows } from "@lib/checklist-adapter"; +import { itemTopologyProjection, LoopCompact, LoopLegend } from "@/components/LoopGraph"; +import "./ChecklistWorkspace.css"; + +function inspectedItem(data: ChecklistProgressData) { + return data.active.find((item) => item.id === data.selectedItemId) ?? data.active[0] ?? null; +} + +function previewRun(item: ChecklistItem, data: ChecklistProgressData): LoopRunProjection { + const live = data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null; + if (live) return live; + return { + schema: "burnlist-loop-read-projection@1", + runId: "preview", + itemRef: `item:preview#${item.id}`, + loopId: item.loop?.selector ?? "direct", + loopRevision: null, + createdAt: 0, + updatedAt: 0, + state: "prepared", + currentNode: "start", + attempt: 0, + cycle: 0, + revision: "preview", + budget: { + limits: { maxRounds: 0, maxMinutes: 0, maxAgentRuns: 0, maxCheckRuns: 0, maxTransitions: 0, maxOutputBytes: 0 }, + counters: { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }, + elapsedMilliseconds: 0, + journal: { maximum: 0, used: 0, remaining: 0 }, + }, + latestResult: null, + graph: item.loop?.graph ?? { entry: "start", nodes: [], edges: [] }, + transitions: [], + }; +} + +function EventsColumn({ data }: { data: ChecklistProgressData }) { + const rows = eventRows(data).slice(0, 10); + return
+
Events{rows.length}
+
+ {rows.map((item) =>
+ {item.id} + {item.title} + {compactAge(item.completedAt, data.generatedAt)} · {item.percent}% +
)} + {!rows.length &&

No completed events

} +
+
; +} + +function ItemsColumn({ data, selected }: { data: ChecklistProgressData; selected: ChecklistItem | null }) { + return
+
Items{data.active.length}
+ +
; +} + +const detailFields = [ + ["Action", "Action"], + ["Done when", "Done/delete when"], + ["Validate", "Validate"], + ["Files", "Files/search"], +] as const; + +function DetailColumn({ data, item }: { data: ChecklistProgressData; item: ChecklistItem | null }) { + if (!item) return

No active item

; + const run = previewRun(item, data); + const topology = item.loop?.graph ? itemTopologyProjection(run) : null; + const legendSymbols = topology ? { + start: "S", + ...Object.fromEntries(topology.graph.nodes.filter((node) => node.kind === "terminal" && node.terminalState === "converged").map((node) => [node.id, "B"])), + } : undefined; + return
+
Item detail{item.id}
+
+
{item.id}

{item.title}

+
{detailFields.map(([label, key]) => item.fields[key] ?
{label}
{item.fields[key]}
: null)}
+ {item.loop ?
+
Loop{item.loop.selector}
+ + {topology && } +
:
Direct implementation · no Loop assigned
} +
+
; +} + +export function ChecklistWorkspace({ data }: { data: ChecklistProgressData }) { + const selected = inspectedItem(data); + return
+ + + +
; +} diff --git a/dashboard/src/oven/ChecklistWorkspace/index.ts b/dashboard/src/oven/ChecklistWorkspace/index.ts new file mode 100644 index 00000000..83ee6c75 --- /dev/null +++ b/dashboard/src/oven/ChecklistWorkspace/index.ts @@ -0,0 +1 @@ +export { ChecklistWorkspace } from "./ChecklistWorkspace"; diff --git a/dashboard/src/oven/runtime/OvenNode.test.ts b/dashboard/src/oven/runtime/OvenNode.test.ts index 358607d8..a1421fad 100644 --- a/dashboard/src/oven/runtime/OvenNode.test.ts +++ b/dashboard/src/oven/runtime/OvenNode.test.ts @@ -28,6 +28,26 @@ test("OvenNode collection each scopes @item and follows paging and search", () = assert.match(render(node, state), /second/); assert.doesNotMatch(render(node, state), /first/); }); +test("OvenNode composes LoopGraph from root and item-scoped sources", () => { + const loopRun = { + loopId: "review", state: "running", currentNode: "verify", attempt: 1, cycle: 0, + graph: { + entry: "implement", + nodes: [{ id: "implement", kind: "agent" }, { id: "verify", kind: "check" }], + edges: [{ from: "implement", on: "complete", to: "verify" }], + }, + transitions: [{ sequence: 1, from: "implement", outcome: "complete", to: "verify" }], + }; + const rootNode: any = { kind: "loop-graph", attributes: { source: "/loopRun" }, children: [] }; + assert.match(render(rootNode, initOvenState(base, { loopRun })), /aria-current="step"/); + assert.match(render(rootNode, initOvenState(base, { loopRun })), /VERIFY/); + const itemNode: any = { + kind: "collection", attributes: { id: "items" }, + children: [{ kind: "each", attributes: {}, children: [{ kind: "loop-graph", attributes: { source: "@item/loopRun" }, children: [] }] }], + }; + assert.match(render(itemNode, initOvenState(base, { items: [{ name: "first", loopRun }] })), /aria-current="step"/); +}); + test("OvenNode sends mode-toggle callbacks through the closed dispatch", () => { const actions: OvenAction[] = []; const node: any = { kind: "mode-toggle", attributes: { id: "mode", ariaLabel: "Mode" }, children: [{ kind: "option", attributes: { value: "one", label: "One" } }, { kind: "option", attributes: { value: "two", label: "Two" } }] }; diff --git a/dashboard/src/oven/runtime/OvenNode.tsx b/dashboard/src/oven/runtime/OvenNode.tsx index 4a3cb2d4..4e497a11 100644 --- a/dashboard/src/oven/runtime/OvenNode.tsx +++ b/dashboard/src/oven/runtime/OvenNode.tsx @@ -12,6 +12,7 @@ import { LogTable } from "../LogTable"; import { formatRegistry } from "../OvenView/registries"; import { buildLogTableProps } from "./log-table-adapter"; import { ModelLabView, type ModelLabPayload } from "../ModelLabView"; +import { LoopGraph, type LoopGraphProjection } from "../../components/LoopGraph"; export type OvenNodeDef = { kind: string; attributes?: Record; bindings?: Record; children?: OvenNodeDef[] }; export type OvenNodeProps = { node: OvenNodeDef; ir: OvenIr; state: OvenState; dispatch: (action: OvenAction) => void; item?: unknown; path?: string }; @@ -25,10 +26,19 @@ function scopedNode(node: OvenNodeDef): OvenNodeDef { const pointer = (source: unknown) => typeof source !== "string" ? source : source === "@item" ? "/__ovenItem" : source.startsWith("@item/") ? `/__ovenItem${source.slice(5)}` : source.startsWith("/") || source === "" ? `/__ovenRoot${source || "/"}` : source; return { ...node, attributes: Object.fromEntries(Object.entries(attrs(node)).map(([key, value]) => [key, key === "source" ? pointer(value) : value])), bindings: Object.fromEntries(Object.entries(node.bindings ?? {}).map(([key, value]) => [key, value && typeof value === "object" ? { ...(value as object), source: pointer((value as { source?: unknown }).source) } : value])), children: (node.children ?? []).map(scopedNode) }; } +function runtimeSource(payload: unknown, item: unknown, source: unknown) { + if (typeof source !== "string") return undefined; + if (source === "@item") return item; + if (source.startsWith("@item/")) return resolvePointer(item, source.slice(5)); + return resolvePointer(payload, source); +} function staticView(node: OvenNodeDef, ir: OvenIr, root: unknown, item?: unknown) { const lowered = lowerOvenIr({ id: "runtime", theme: ir.theme, root: [item === undefined ? node : scopedNode(node)] }); const payload = item === undefined ? root : { __ovenRoot: root, __ovenItem: item }; - return ; + const sections = lowered.sections.length === 1 && lowered.sections[0].element === "div" && !lowered.sections[0].className + ? [{ ...lowered.sections[0], element: "fragment" as const }] + : lowered.sections; + return ; } function layoutStyle(node: OvenNodeDef): Record { const a = attrs(node); @@ -58,8 +68,12 @@ export function OvenNode({ node, ir, state, dispatch, item, path = "root" }: Ove } if (node.kind === "each") return <>{(node.children ?? []).map((child, index) => )}; if (node.kind === "model-lab-view") return ; + if (node.kind === "loop-graph") { + const run = runtimeSource(state.payload, item, attrs(node).source) as (LoopGraphProjection & { diagnostic?: "corrupt" | "stale" }) | null | undefined; + return ; + } if (node.kind === "log-table") return ; - if (["checklist-burn-panel", "checklist-ledger", "checklist-event-cards"].includes(node.kind)) return ; + if (["checklist-current", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards"].includes(node.kind)) return ; if (["mode-toggle", "domain-tabs", "field-toolbar", "pagination"].includes(node.kind)) return ; if (["field-list", "refresh-status", "verdict-header", "metric-tiles", "domain-note", "frame-card"].includes(node.kind)) return ; if (node.kind === "box") return {(node.children ?? []).map((child, index) => )}; diff --git a/dashboard/src/oven/runtime/OvenRuntime.tsx b/dashboard/src/oven/runtime/OvenRuntime.tsx index f4301d0c..1bffc8ad 100644 --- a/dashboard/src/oven/runtime/OvenRuntime.tsx +++ b/dashboard/src/oven/runtime/OvenRuntime.tsx @@ -62,6 +62,10 @@ export function OvenRuntime({ ir, initialPayload, payload, controls, pages, init ?
{state.refresh.phase === "failed" ? `Showing the last canonical snapshot. ${String(state.refresh.error || "Canonical refresh failed.")}` : "Showing the last canonical snapshot while canonical data refreshes."}
: null; const staticView = !themedView && root.every(isStaticOvenDocument) ? : null; - const genericView = themedRegions(root, theme, (node, index) => ) ?? <>{root.map((node, index) => )}; + const regions = themedRegions(root, theme, (node, index) => ); + const genericBody = regions ?? <>{root.map((node, index) => )}; + const genericView = regions && (theme?.view.shellClassName || theme?.view.bodyClassName || theme?.view.bodyId) + ?
{genericBody}
+ : genericBody; return {staleState}{emptyState ?? themedView ?? staticView ?? genericView}; } diff --git a/dashboard/src/oven/runtime/theme-registry.ts b/dashboard/src/oven/runtime/theme-registry.ts index 04f622ad..3673b445 100644 --- a/dashboard/src/oven/runtime/theme-registry.ts +++ b/dashboard/src/oven/runtime/theme-registry.ts @@ -34,7 +34,7 @@ const checklist: OvenTheme = Object.freeze({ }), regions: Object.freeze([ Object.freeze({ kinds: Object.freeze(["kpi-strip"]), element: "section", className: "differential-overview checklist-overview" }), - Object.freeze({ kinds: Object.freeze(["checklist-ledger", "checklist-burn-panel"]), element: "div", className: "detail-workspace checklist-progress-workspace", props: Object.freeze({ "data-detail-tab": "dashboard" }) }), + Object.freeze({ kinds: Object.freeze(["checklist-ledger", "checklist-burn-panel", "checklist-current"]), element: "div", className: "detail-workspace checklist-progress-workspace", props: Object.freeze({ "data-detail-tab": "dashboard" }) }), Object.freeze({ kinds: Object.freeze(["checklist-event-cards"]), element: "fragment" }), ]), kpiItemVariants: Object.freeze({ current: "checklist-kpi-current" }), diff --git a/dashboard/src/oven/runtime/widget-adapters.tsx b/dashboard/src/oven/runtime/widget-adapters.tsx index 577cedaf..226ff15e 100644 --- a/dashboard/src/oven/runtime/widget-adapters.tsx +++ b/dashboard/src/oven/runtime/widget-adapters.tsx @@ -3,6 +3,7 @@ import { RefreshStatusChip } from "../RefreshStatusChip/RefreshStatusChip"; import { ChecklistBurnPanel } from "../ChecklistBurnPanel/ChecklistBurnPanel"; import { ChecklistEventCards } from "../ChecklistEventCards/ChecklistEventCards"; import { ChecklistLedger } from "../ChecklistLedger/ChecklistLedger"; +import { ChecklistCurrent } from "../ChecklistCurrent/ChecklistCurrent"; import { DomainNote } from "../DomainNote"; import { FrameCard } from "../FrameCard"; import { MetricTiles } from "../MetricTiles"; @@ -57,6 +58,7 @@ export function WidgetAdapter({ node, ir, state, dispatch }: { node: Node; ir: O export function ChecklistWidgetAdapter({ node, payload }: { node: Node; payload: unknown }) { const data = resolvePointer(payload, String(attrs(node).source ?? "/")) as any; + if (node.kind === "checklist-current") return ; if (node.kind === "checklist-burn-panel") return ; if (node.kind === "checklist-ledger") return ; if (node.kind === "checklist-event-cards") return ; diff --git a/dashboard/src/oven/test-support/run-oven-tests.mjs b/dashboard/src/oven/test-support/run-oven-tests.mjs index 407cb852..e27748fb 100644 --- a/dashboard/src/oven/test-support/run-oven-tests.mjs +++ b/dashboard/src/oven/test-support/run-oven-tests.mjs @@ -58,8 +58,8 @@ function discoverBundledTests(directory) { return files.sort(); } -const testEntries = discoverTests(ovenDir); -console.log(`=== Oven TypeScript tests (${testEntries.length}) ===`); +const testEntries = discoverTests(sourceDir); +console.log(`=== Dashboard TypeScript tests (${testEntries.length}) ===`); if (testEntries.length === 0) process.exit(0); @@ -76,7 +76,7 @@ try { packages: "external", sourcemap: "inline", outdir: outputDir, - outbase: ovenDir, + outbase: sourceDir, entryNames: "[dir]/[name]", outExtension: { ".js": ".mjs" }, alias, diff --git a/ovens/checklist/checklist.oven b/ovens/checklist/checklist.oven index 023f20c7..34a74272 100644 --- a/ovens/checklist/checklist.oven +++ b/ovens/checklist/checklist.oven @@ -11,5 +11,6 @@ + diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 2012abda..6fb163f1 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BHMojAeL.js", - "dashboard/dist/assets/index-qhZB46pA.css", + "dashboard/dist/assets/index-WF9rXGwV.css", + "dashboard/dist/assets/index-shfLdIoB.js", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/review/instructions.md", @@ -140,8 +140,8 @@ "src/loops/dsl/package-read.mjs", "src/loops/events/projection-events.mjs", "src/loops/run/binder.mjs", - "src/loops/run/candidate.mjs", "src/loops/run/budgets.mjs", + "src/loops/run/candidate.mjs", "src/loops/run/controller.mjs", "src/loops/run/current-authority.mjs", "src/loops/run/launch-commit.mjs", diff --git a/scripts/verify-source-scan.mjs b/scripts/verify-source-scan.mjs index 152880b9..7775d2c8 100644 --- a/scripts/verify-source-scan.mjs +++ b/scripts/verify-source-scan.mjs @@ -1,5 +1,6 @@ const EXCLUDED_PREFIXES = [ ".git/", + ".burnlist/", ".claude/", ".local/", ".playwright-cli/", diff --git a/scripts/verify-source-scan.test.mjs b/scripts/verify-source-scan.test.mjs index 18dd8fbe..3c80f056 100644 --- a/scripts/verify-source-scan.test.mjs +++ b/scripts/verify-source-scan.test.mjs @@ -6,6 +6,7 @@ test("source leak scanning excludes local and nested checkout state", () => { for (const path of [ ".git/config", ".local/burnlist/state.json", + ".burnlist/loop-capabilities.json", ".worktrees/feature/.git", ".worktrees/feature/src/private.mjs", "node_modules/package/index.js", diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index 00372043..f4c73abe 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -204,6 +204,19 @@ Run lease. For live foreground work, the owner handles Ctrl-C: first interrupt requests pause after its child exits, second requests controlled stop. Do not claim that a separate CLI invocation can take over a live Run. +The canonical Checklist Oven keeps the progress-row Loop bound to the first +active item; inspecting another `#` changes only the three-column +Events / Items / Item detail workspace below it. A Loop-assigned current item +shows its compact topology beside Completion with the active node highlighted +and a graph-derived legend; a direct current item collapses that panel. Item +detail shows the selected item's contract, Loop preview or live Run, labelled +return paths, and a graph-derived node legend. Ovens may compose Burnlist's +core box-drawing renderer declaratively with +``, or with +`` inside a collection. The widget never +fetches, executes, or imports custom code; core transport refreshes it after +item-keyed Loop invalidation events. + Stage 1 labels fresh reviewer process, graph grammar, budgets, closed outcomes, and atomic canonical CLI writes `enforced`; ordinary drift checks are `detected-at-boundaries`; reviewer filesystem write denial is `supervised`. diff --git a/skills/burnlist/references/creating-ovens.md b/skills/burnlist/references/creating-ovens.md index 232716e2..f30cdf64 100644 --- a/skills/burnlist/references/creating-ovens.md +++ b/skills/burnlist/references/creating-ovens.md @@ -108,6 +108,7 @@ declared bounds. `panel` requires `id`. | `image-triptych`, `feed-list`, `diff-card`, `file-diff` | `image-triptych` and `feed-list` take `id` and `bind`. `diff-card` and `file-diff` take `id`, `source`, `format`, `optional`, `fallback`, `slot`, and may contain `bind`, `text`, `icon`. | | `refresh-status` / `streaming-diff-heading` | `refresh-status` takes `id`, `source`, `format`, `optional`, `fallback`, `slot`. `streaming-diff-heading` takes `id`, `session`, `back-href`. | | Checklist widgets | `checklist-burn-panel`, `checklist-ledger`, and `checklist-event-cards` each take `id`, `source`, `format`, `optional`, `fallback`, `slot`. | +| Loop graph | `loop-graph` requires `source` and optionally takes `id`, `title`, `format`, `optional`, `fallback`, `slot`. Use a root pointer for a global placement or `@item/loopRun` inside `each`; Burnlist owns rendering and live projection refresh. | | Differential widgets | `differential-kpi-strip`, `differential-log-table`, `progress-chart`, and `frame-delta-chart` each take `id`, `source`, `format`, `optional`, `fallback`, `slot`. `differential-empty-state` takes `id`, `title`. | Only registered icons may be used by `` or `kpi-item` @@ -120,7 +121,7 @@ Only registered icons may be used by `` or `kpi-item` | --- | --- | | `switch` / `case` | A switch takes `id`, `source`, `mode-from` and contains `case`; it requires exactly one of `source` or `mode-from`. A case takes `value`, `default`, and requires either `value` or `default="true"`. | | `collection` | Requires `id`, `source`, `item-key`, `paging`, `page-size`; it may also use `search-from`, `filter-from`, `sort-from`, and contains `each`, `field-list`, and/or `pagination`. `paging` is `client`, `server`, or `auto`. | -| `each` / `field-list` / `pagination` | `each` has no attributes and contains a grid, stack, panel, KPI item, section header, table, or switch. `field-list` takes `id`, `collection-from`, `mode-from` and contains `bind`. `pagination` requires `collection-from`, `page-sizes`, and must be inside a collection. | +| `each` / `field-list` / `pagination` | `each` has no attributes and contains a grid, stack, panel, KPI item, section header, table, `loop-graph`, or switch. `field-list` takes `id`, `collection-from`, `mode-from` and contains `bind`. `pagination` requires `collection-from`, `page-sizes`, and must be inside a collection. | | `field-toolbar` | Requires `id` and contains `search`, `mode-toggle`, `sort-toggle`, and/or `filter-toggle`. | | Toolbar controls | `search`: `id`, `placeholder`, `aria-label`, `match-fields`, `debounce-ms`; `mode-toggle`: `id`, `initial`, `aria-label`, with at least two `option` children; `option`: `value`, `label`; `sort-toggle`: `id`, `key`, `label`, `initial`, `requires-source`, `requires-value`, `unavailable-text`; `filter-toggle`: `id`, `key`, `label`, `initial`. | diff --git a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json index 5c5f902f..c6ccc098 100644 --- a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json +++ b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json @@ -1,7 +1,27 @@ [ - {"checkpoint":"needs-human","domBytes":12386,"domSha256":"f1b61d53b386eac2517abb1a8e6f8780c996e884d1a3a9fadb539215f49182dd"}, - {"checkpoint":"paused","domBytes":11628,"domSha256":"6e2e705700cccc3f90713cbea6d4c99796723c35d39af21597ea69e66469c144"}, - {"checkpoint":"repair","domBytes":12494,"domSha256":"4eb6984ea066104d1ff2dc84d80f1e40e4db18c25936a97bcab4527721628844"}, - {"checkpoint":"converged","domBytes":12746,"domSha256":"0f68c9e8e272e2971472ea204e4c6cb5d6595e23c6e4d9a9d3f6a84ed6f79ee6"}, - {"checkpoint":"post-completion","domBytes":9212,"domSha256":"3d84894369b43754d20a2b134d3509fd8460bfd5dc52c0b52127b5bfc0897cf2"} + { + "checkpoint": "needs-human", + "domBytes": 10217, + "domSha256": "716f2668931ac3f80620c2c13cabed72a9b504383c6d5cb7d08daddf9faa013c" + }, + { + "checkpoint": "paused", + "domBytes": 10215, + "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + }, + { + "checkpoint": "repair", + "domBytes": 10215, + "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + }, + { + "checkpoint": "converged", + "domBytes": 10215, + "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + }, + { + "checkpoint": "post-completion", + "domBytes": 8510, + "domSha256": "e66102e9fc69180db39b08ba34c490b2793216d1cbe102df0c44aa52619337de" + } ] diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs index f539a549..b267ea7b 100644 --- a/src/loops/minimal-review-e2e.test.mjs +++ b/src/loops/minimal-review-e2e.test.mjs @@ -55,8 +55,14 @@ async function dashboardRenderer(t) { latestMaker: loopRun.latestMaker && { ...loopRun.latestMaker, at: base + 1_000, candidateId: alias(loopRun.latestMaker.candidateId) }, latestCheck: loopRun.latestCheck && { ...loopRun.latestCheck, at: base + 2_000, candidateId: alias(loopRun.latestCheck.candidateId) }, latestReviewer: loopRun.latestReviewer && { ...loopRun.latestReviewer, at: base + 3_000, candidateId: alias(loopRun.latestReviewer.candidateId) } }; + const active = stableRun ? [{ + id: stableRun.itemRef.split("#").at(-1), + title: "Loop-assigned item", + fields: {}, + loop: { selector: `loop:builtin:${stableRun.loopId}` }, + }] : []; const dom = serializeCanonical(normalize(parseHtml(withDeterministicTime(() => - renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, loopRun: stableRun } })) )))); + renderToStaticMarkup(createElement(ChecklistDashboard, { data: { ...checklistFixture, active, loopRun: stableRun } })) )))); return { record: { checkpoint, domBytes: Buffer.byteLength(dom), domSha256: digest(dom) }, dom }; }; } @@ -90,7 +96,8 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; assert.deepEqual(escalationProjection, escalationInspection); const needsHumanUi = render("needs-human", escalationProjection); - assert.match(needsHumanUi.dom, /aria-label="Loop state: Needs human review"/u); + assert.match(needsHumanUi.dom, /
/u); + assert.match(needsHumanUi.dom, /aria-current="step"/u); assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(escalation).toString("hex"), "completion-receipt.json")), false); assert.match(readFileSync(planPath, "utf8"), /- \[ \] L29/u); diff --git a/src/loops/run/binder.test.mjs b/src/loops/run/binder.test.mjs index 55e720c9..b48ed576 100644 --- a/src/loops/run/binder.test.mjs +++ b/src/loops/run/binder.test.mjs @@ -8,6 +8,7 @@ import { loadBoundPolicy } from "./run-artifacts.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { ownerClaimId } from "./run-claim.mjs"; import { runStore } from "./run-store.mjs"; +import { presentRun } from "./read-projection.mjs"; import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; test("production creation binds direct Stage One profiles without Docker artifacts", async (t) => { @@ -106,6 +107,12 @@ test("stored production repair binds a fresh repository candidate and gives each t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); const created = await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + const publicRun = presentRun(store.read(fixtureRunId)); + assert.deepEqual(publicRun.graph.nodes.find((node) => node.id === "implement").execution, + { profileId: "maker", model: "gpt-5.3-codex-spark", effort: "medium", authority: "write" }); + assert.deepEqual(publicRun.graph.nodes.find((node) => node.id === "review").execution, + { profileId: "reviewer", model: "gpt-5.3-codex-spark", effort: "medium", authority: "read" }); + assert.doesNotMatch(JSON.stringify(publicRun), /\/bin\/sh|executableDigest|adapter/u); const outcomes = ["complete", "reject", "complete", "approve"], reviewerPrompts = []; let writes = 0; const startAgent = ({ prompt }) => { const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index e13c6fc0..f76ec38f 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -11,8 +11,34 @@ import { runStore } from "./run-store.mjs"; const MAX_RUNS = 128; const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; -function publicNode(node) { - return { id: node.id, kind: node.kind }; +function publicNode(node, routes = []) { + const common = { id: node.id, kind: node.kind }; + if (node.kind === "agent") { + const resolved = routes.find((entry) => entry.route === node.route); + return { + ...common, + role: node.role, + authority: node.authority, + execution: resolved ? { + profileId: resolved.profileId, + model: resolved.model, + effort: resolved.effort, + authority: resolved.authority, + } : null, + }; + } + if (node.kind === "check") return { ...common, capability: node.capability }; + if (node.kind === "gate") return { ...common, gateKind: node.gateKind }; + if (node.kind === "terminal") return { ...common, terminalState: node.state }; + return common; +} + +export function presentGraph(graph, routes = []) { + return Object.freeze({ + entry: graph.entry, + nodes: graph.nodes.map((node) => publicNode(node, routes)), + edges: graph.edges.map(({ from, on, to }) => ({ from, on, to })), + }); } export function presentRun(replay) { @@ -50,11 +76,7 @@ export function presentRun(replay) { elapsedMilliseconds: replay.execution.budget.elapsedMilliseconds, journal: replay.execution.budget.journal, }, - graph: { - entry: replay.graph.entry, - nodes: replay.graph.nodes.map(publicNode), - edges: replay.graph.edges.map(({ from, on, to }) => ({ from, on, to })), - }, + graph: presentGraph(replay.graph, replay.agentRoutes), transitions, }); } @@ -107,6 +129,8 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI } if (current && !selected) fail("current Run is unavailable", "ECURRENT"); if (!selected) return null; - selected.loopIdentity = runStore(repoRoot).read(selected.projection.runId).loopIdentity; + const stored = runStore(repoRoot).read(selected.projection.runId); + selected.loopIdentity = stored.loopIdentity; + selected.agentRoutes = stored.agentRoutes; return presentRun(selected); } diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs index cb7879bd..a6c45c67 100644 --- a/src/loops/run/run-store.mjs +++ b/src/loops/run/run-store.mjs @@ -10,6 +10,7 @@ import { parseBoundedObject } from "../contracts/contract.mjs"; import { publishLoopProjectionInvalidation } from "../events/projection-events.mjs"; import { currentRunAuthority } from "./current-authority.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { loadBoundPolicy } from "./run-artifacts.mjs"; const fail = (message, code = "ERUN_STORE") => { throw Object.assign(new Error(`Run store: ${message}`), { code }); }; const runName = (id) => Buffer.from(id).toString("hex"); @@ -24,18 +25,28 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy const journal = readJournal(journalFor(id)), folded = foldRun(journal); if (folded.projection.runId !== id) fail("run identity mismatch"); let loopIdentity = Object.freeze({ loopId: folded.graph.id, loopRevision: null }); + let agentRoutes = Object.freeze([]); if (existsSync(authorityPath(id))) { try { const authority = readAuthority(id), frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); if (authority.itemRef !== folded.projection.itemRef) fail("sealed authority item does not match Run journal", "EAUTHORITY"); if (JSON.stringify(frozen.ir) !== JSON.stringify(folded.graph)) fail("sealed recipe does not match Run graph", "EAUTHORITY"); + const policy = loadBoundPolicy(Buffer.from(authority.policy, "base64")).policy; loopIdentity = Object.freeze({ loopId: frozen.ir.id, loopRevision: frozen.revisions.executable }); + agentRoutes = Object.freeze(policy.routes.map(({ route, profile }) => Object.freeze({ + route, + profileId: profile.id, + adapter: profile.adapter, + model: profile.model, + effort: profile.effort, + authority: profile.authority, + }))); } catch (error) { if (error?.code === "EAUTHORITY") throw error; fail("sealed dispatch authority is corrupt", "EAUTHORITY"); } } else if (journal[0].value.payload.authorityRequired) fail("sealed dispatch authority is unavailable", "EAUTHORITY"); - return Object.freeze({ runId: id, journal, loopIdentity, ...folded }); + return Object.freeze({ runId: id, journal, loopIdentity, agentRoutes, ...folded }); }; const retainsTerminalReserve = (current, writes = 1) => current.projection.sequence + writes < MAX_JOURNAL_RECORDS; const terminalKind = { converged: "converged", "needs-human": "lost", failed: "error", stopped: "cancelled", "budget-exhausted": "exhausted" }; diff --git a/src/ovens/dsl/oven-compile.test.mjs b/src/ovens/dsl/oven-compile.test.mjs index a70eb071..e38a20e7 100644 --- a/src/ovens/dsl/oven-compile.test.mjs +++ b/src/ovens/dsl/oven-compile.test.mjs @@ -28,3 +28,14 @@ test("oven version requires major.minor.patch", () => { assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === "SCALAR_VERSION"), JSON.stringify(result.diagnostics)); } }); + +test("loop-graph is a closed root or item-scoped Oven component", () => { + const root = compileOven(''); + assert.equal(root.ok, true, JSON.stringify(root.diagnostics)); + assert.ok(root.ir.requirements.components.includes("loop-graph")); + const item = compileOven(''); + assert.equal(item.ok, true, JSON.stringify(item.diagnostics)); + const missing = compileOven(''); + assert.equal(missing.ok, false); + assert.ok(missing.diagnostics.some((diagnostic) => diagnostic.code === "GRAMMAR_REQUIRED")); +}); diff --git a/src/ovens/dsl/oven-grammar.mjs b/src/ovens/dsl/oven-grammar.mjs index da0f3f9b..b2a6b46f 100644 --- a/src/ovens/dsl/oven-grammar.mjs +++ b/src/ovens/dsl/oven-grammar.mjs @@ -1,19 +1,19 @@ const common = ["id"]; const sourceBinding = ["source", "format", "optional", "fallback", "slot"]; export const ELEMENTS = Object.freeze({ - oven: { parents: [], attrs: ["id", "version", "contract", "refresh-seconds", "theme"], children: ["box", "grid", "stack", "panel", "kpi-strip", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "streaming-diff-heading", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state", "model-lab-view"] }, - box: { attrs: [...common, "element", "class", "text", "data-detail-tab"], children: ["box", "grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "refresh-status"] }, - grid: { attrs: [...common, "columns", "rows", "row-height"], children: ["box", "panel", "stack", "kpi-strip", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch"] }, - stack: { attrs: [...common, "direction", "gap"], children: ["box", "grid", "panel", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "refresh-status"] }, - panel: { attrs: ["id", "title", "column", "row", "column-span", "row-span"], children: ["box", "grid", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "mode-toggle"] }, - switch: { attrs: [...common, "mode-from", "source"], children: ["case"] }, case: { attrs: ["value", "default"], children: ["grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "collection", "field-toolbar", "mode-toggle", "switch", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state"] }, - collection: { attrs: ["id", "source", "item-key", "search-from", "filter-from", "sort-from", "paging", "page-size"], children: ["each", "field-list", "pagination"] }, each: { attrs: [], children: ["grid", "stack", "panel", "kpi-item", "section-header", "log-table", "switch"] }, + oven: { parents: [], attrs: ["id", "version", "contract", "refresh-seconds", "theme"], children: ["box", "grid", "stack", "panel", "kpi-strip", "section-header", "log-table", "loop-graph", "checklist-current", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "streaming-diff-heading", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state", "model-lab-view"] }, + box: { attrs: [...common, "element", "class", "text", "data-detail-tab"], children: ["box", "grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "refresh-status"] }, + grid: { attrs: [...common, "columns", "rows", "row-height"], children: ["box", "panel", "stack", "kpi-strip", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch"] }, + stack: { attrs: [...common, "direction", "gap"], children: ["box", "grid", "panel", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "refresh-status"] }, + panel: { attrs: ["id", "title", "column", "row", "column-span", "row-span"], children: ["box", "grid", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "mode-toggle"] }, + switch: { attrs: [...common, "mode-from", "source"], children: ["case"] }, case: { attrs: ["value", "default"], children: ["grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "collection", "field-toolbar", "mode-toggle", "switch", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state"] }, + collection: { attrs: ["id", "source", "item-key", "search-from", "filter-from", "sort-from", "paging", "page-size"], children: ["each", "field-list", "pagination"] }, each: { attrs: [], children: ["grid", "stack", "panel", "kpi-item", "section-header", "log-table", "loop-graph", "switch"] }, bind: { attrs: ["prop", "source", "format", "optional", "fallback"], children: [] }, text: { attrs: ["slot", "text", "source", "format", "optional", "fallback"], children: [] }, icon: { attrs: ["slot", "name"], children: [] }, column: { attrs: ["label", "source", "format", "optional", "fallback", "tone"], children: [] }, "kpi-strip": { attrs: [...common, "aria-label", "class", "title"], children: ["kpi-item"] }, "kpi-item": { attrs: [...common, "class", "heading", "title", "value", "icon", "variant", ...sourceBinding], children: ["bind", "text", "icon", "progress-donut", "burn-donut", "waffle-metric", "progress-value"] }, - "progress-donut": { attrs: sourceBinding, children: [] }, "burn-donut": { attrs: sourceBinding, children: [] }, "waffle-metric": { attrs: sourceBinding, children: [] }, "progress-value": { attrs: ["done", "total", "percent"], children: [] }, "checklist-burn-panel": { attrs: sourceBinding, children: [] }, "checklist-ledger": { attrs: sourceBinding, children: [] }, "checklist-event-cards": { attrs: sourceBinding, children: [] }, "log-table": { attrs: [...common, "class", "title", "source", "empty-text"], children: ["column"] }, "section-header": { attrs: [...common, "class", "title", ...sourceBinding], children: ["bind", "text", "icon"] }, "streaming-diff-heading": { attrs: [...common, "session", "back-href"], children: [] }, + "progress-donut": { attrs: sourceBinding, children: [] }, "burn-donut": { attrs: sourceBinding, children: [] }, "waffle-metric": { attrs: sourceBinding, children: [] }, "progress-value": { attrs: ["done", "total", "percent"], children: [] }, "checklist-current": { attrs: sourceBinding, children: [] }, "checklist-burn-panel": { attrs: sourceBinding, children: [] }, "checklist-ledger": { attrs: sourceBinding, children: [] }, "checklist-event-cards": { attrs: sourceBinding, children: [] }, "log-table": { attrs: [...common, "class", "title", "source", "empty-text"], children: ["column"] }, "section-header": { attrs: [...common, "class", "title", ...sourceBinding], children: ["bind", "text", "icon"] }, "streaming-diff-heading": { attrs: [...common, "session", "back-href"], children: [] }, "field-list": { attrs: [...common, "collection-from", "mode-from"], children: ["bind"] }, "metric-tiles": { attrs: [...common, "source", "selection-from"], children: ["bind"] }, "verdict-header": { attrs: common, children: ["bind"] }, "domain-tabs": { attrs: ["id", "source", "initial-source", "format"], children: [] }, "domain-note": { attrs: [...common, "source", "selection-from"], children: ["bind"] }, "frame-card": { attrs: [...common, ...sourceBinding, "selection-from"], children: ["bind", "text", "icon"] }, "image-triptych": { attrs: common, children: ["bind"] }, "feed-list": { attrs: common, children: ["bind"] }, "diff-card": { attrs: [...common, ...sourceBinding], children: ["bind", "text", "icon"] }, "file-diff": { attrs: [...common, ...sourceBinding], children: ["bind", "text", "icon"] }, "refresh-status": { attrs: [...common, ...sourceBinding], children: [] }, "differential-kpi-strip": { attrs: [...common, ...sourceBinding], children: [] }, "differential-log-table": { attrs: [...common, ...sourceBinding], children: [] }, "progress-chart": { attrs: [...common, ...sourceBinding], children: [] }, "frame-delta-chart": { attrs: [...common, ...sourceBinding], children: [] }, "differential-empty-state": { attrs: [...common, "title"], children: [] }, - "model-lab-view": { attrs: [...common, "source"], children: [] }, + "model-lab-view": { attrs: [...common, "source"], children: [] }, "loop-graph": { attrs: [...common, "title", ...sourceBinding], children: [] }, "field-toolbar": { attrs: ["id"], children: ["search", "mode-toggle", "sort-toggle", "filter-toggle"] }, "mode-toggle": { attrs: ["id", "initial", "aria-label"], children: ["option"] }, option: { attrs: ["value", "label"], children: [] }, search: { attrs: ["id", "placeholder", "aria-label", "match-fields", "debounce-ms"], children: [] }, "sort-toggle": { attrs: ["id", "key", "label", "initial", "requires-source", "requires-value", "unavailable-text"], children: [] }, "filter-toggle": { attrs: ["id", "key", "label", "initial"], children: [] }, pagination: { attrs: ["collection-from", "page-sizes"], children: [] }, }); export const COMPONENTS = new Set(Object.keys(ELEMENTS).filter((k) => !["oven","grid","stack","panel","switch","case","collection","each","bind","text","icon","column","field-toolbar","mode-toggle","option","search","sort-toggle","filter-toggle","pagination"].includes(k))); diff --git a/src/ovens/dsl/oven-validate.mjs b/src/ovens/dsl/oven-validate.mjs index 46193f1d..d40034aa 100644 --- a/src/ovens/dsl/oven-validate.mjs +++ b/src/ovens/dsl/oven-validate.mjs @@ -52,7 +52,7 @@ export function validateOven(ast, { file = "" } = {}) { gridChecks(ast, d); switchChecks(ast, d); propChecks(ast, d); interactionChecks(ast, d); return { ok: d.list.length === 0, diagnostics: d.list, ids, collections }; } -function requiredAttrs(name) { return ({ oven: ["id", "version", "contract", "theme"], box: ["element"], grid: ["columns"], panel: ["id"], collection: ["id", "source", "item-key", "paging", "page-size"], bind: ["prop", "source"], icon: ["slot", "name"], column: ["label", "source"], "log-table": ["source"], "model-lab-view": ["source"], "mode-toggle": ["id", "initial", "aria-label"], option: ["value", "label"], search: ["id", "placeholder", "aria-label", "match-fields"], "sort-toggle": ["id", "key", "label", "initial"], "filter-toggle": ["id", "key", "label", "initial"], pagination: ["collection-from", "page-sizes"], "field-toolbar": ["id"] })[name] ?? []; } +function requiredAttrs(name) { return ({ oven: ["id", "version", "contract", "theme"], box: ["element"], grid: ["columns"], panel: ["id"], collection: ["id", "source", "item-key", "paging", "page-size"], bind: ["prop", "source"], icon: ["slot", "name"], column: ["label", "source"], "log-table": ["source"], "model-lab-view": ["source"], "loop-graph": ["source"], "mode-toggle": ["id", "initial", "aria-label"], option: ["value", "label"], search: ["id", "placeholder", "aria-label", "match-fields"], "sort-toggle": ["id", "key", "label", "initial"], "filter-toggle": ["id", "key", "label", "initial"], pagination: ["collection-from", "page-sizes"], "field-toolbar": ["id"] })[name] ?? []; } function gridChecks(ast, d) { walk(ast, (grid) => { if (grid.name !== "grid") return; const cols = +grid.attrs.columns, rows = +(grid.attrs.rows ?? 0), boxes = []; for (const panel of grid.children.filter((x) => x.name === "panel" && x.attrs.column)) { const x = +panel.attrs.column, y = +panel.attrs.row, w = +(panel.attrs["column-span"] ?? 1), h = +(panel.attrs["row-span"] ?? 1); if (x + w - 1 > cols || (rows && y + h - 1 > rows)) add(d, "STRUCTURE_GRID_BOUNDS", "Panel is outside grid bounds", panel); for (const b of boxes) if (x <= b.x + b.w - 1 && b.x <= x + w - 1 && y <= b.y + b.h - 1 && b.y <= y + h - 1) add(d, "STRUCTURE_GRID_OVERLAP", "Panels overlap", panel); boxes.push({ x, y, w, h }); } }); } function switchChecks(ast, d) { walk(ast, (node) => { if (node.name !== "switch") return; const cases = node.children.filter((x) => x.name === "case"), values = new Set(), defaults = cases.filter((x) => x.attrs.default === "true"); if (!cases.length) add(d, "STRUCTURE_SWITCH", "Switch requires a case", node); if (defaults.length > 1) add(d, "STRUCTURE_SWITCH", "Switch may have only one default", node); for (const c of cases) { if (c.attrs.value && values.has(c.attrs.value)) add(d, "STRUCTURE_SWITCH", "Switch case values must be unique", c); values.add(c.attrs.value); } }); } function propChecks(ast, d) { walk(ast, (node) => { const required = REQUIRED_PROPS[node.name]; if (!required) return; const bound = node.children.filter((x) => x.name === "bind").map((x) => x.attrs.prop); for (const prop of required) if (bound.filter((x) => x === prop).length !== 1) add(d, "PROPS_REQUIRED", `<${node.name}> requires exactly one bind for ${prop}`, node); }); } diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 2917b740..1bc281f3 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -36,7 +36,8 @@ import { starterOvenSource } from "../ovens/oven-starter.mjs"; import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; -import { readLatestRunForItem } from "../loops/run/read-projection.mjs"; +import { presentGraph, readLatestRunForItem } from "../loops/run/read-projection.mjs"; +import { assignmentStore } from "../loops/assignment/store.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { createOvenJsonSnapshotStore, OVEN_JSON_CACHE_MAX_BYTES } from "./oven-json-snapshot.mjs"; import { discoverBurnlistSummaries } from "./burnlist-discovery.mjs"; @@ -993,7 +994,7 @@ function selectedBurnlist(url) { return { error: active.length ? "Select a Burnlist." : "No active Burnlist found.", burnlists }; } -function payloadForPlan(selection) { +function payloadForPlan(selection, selectedItemId = null) { const plan = parsePlan(selection.planPath, maxPlanBytes); const goal = documentPayloadForPlan(selection.planPath, "goal.md", "Goal", maxPlanBytes); const completedLog = documentPayloadForPlan(selection.planPath, "completed.md", "Completed log", maxPlanBytes); @@ -1035,13 +1036,36 @@ function payloadForPlan(selection) { title: plan.title, planPath: selection.planPath, planLabel: plan.planLabel, + selectedItemId: plan.items.some((item) => item.id === selectedItemId) ? selectedItemId : plan.items[0]?.id ?? null, total, done, remaining, percent, warnings: issues, goal, - active: plan.items, + active: plan.items.map((item) => { + let assignment = null; + try { assignment = loopAssignmentForItem(plan.markdown, item.id); } catch {} + let graph = null; + if (assignment) { + try { + const artifact = assignmentStore(selection.repoRoot).load(assignment["Assignment-Id"]); + if (artifact.itemRef === `item:${burnlistId}#${item.id}` + && artifact.executionRevision === assignment["Execution-Revision"] + && artifact.packageRevision === assignment["Package-Revision"]) graph = presentGraph(artifact.frozen.ir); + } catch {} + } + return { + ...item, + loop: assignment ? { + selector: assignment.Selector, + assignmentId: assignment["Assignment-Id"], + executionRevision: assignment["Execution-Revision"], + packageRevision: assignment["Package-Revision"], + graph, + } : null, + }; + }), completed: plan.completed.map((entry) => ({ ...entry, detail: completedDetails.get(entry.id)?.detail ?? "", @@ -1053,9 +1077,12 @@ function payloadForPlan(selection) { }; } -function loopProjectionForPlan(selection) { +function loopProjectionForPlan(selection, requestedItemId = null) { const plan = parsePlan(selection.planPath, maxPlanBytes); - const currentItem = plan.items[0]; + const currentItem = requestedItemId + ? plan.items.find((item) => item.id === requestedItemId) + : plan.items.find((item) => loopAssignmentForItem(plan.markdown, item.id)); + if (requestedItemId && !currentItem) throw Object.assign(new Error("Loop item is not active in the selected Burnlist"), { code: "EITEM" }); const assignment = currentItem ? loopAssignmentForItem(plan.markdown, currentItem.id) : null; if (!currentItem || !assignment) return null; return readLatestRunForItem({ @@ -1282,7 +1309,7 @@ const server = createServer(async (req, res) => { return; } try { - json(res, 200, payloadForPlan(selected.burnlist)); + json(res, 200, payloadForPlan(selected.burnlist, url.searchParams.get("item"))); } catch (err) { json(res, 500, { error: err.message }); } @@ -1294,10 +1321,10 @@ const server = createServer(async (req, res) => { if (!selected.burnlist) return json(res, 409, { error: selected.error }); try { // This representation is deliberately only the sanitized canonical projection. - serveLoopProjection(req, res, loopProjectionForPlan(selected.burnlist)); + serveLoopProjection(req, res, loopProjectionForPlan(selected.burnlist, url.searchParams.get("item"))); } catch (error) { - const status = error?.code === "EAMBIGUOUS" || error?.code === "ECORRUPT" || error?.code === "ERUN_PROJECTION" || error?.code === "EAUTHORITY" ? 409 : 500; - json(res, status, { error: status === 409 ? "Loop projection is unavailable; retaining the last verified projection." : "Loop projection is unavailable." }); + const status = error?.code === "EITEM" ? 404 : error?.code === "EAMBIGUOUS" || error?.code === "ECORRUPT" || error?.code === "ERUN_PROJECTION" || error?.code === "EAUTHORITY" ? 409 : 500; + json(res, status, { error: status === 404 ? error.message : status === 409 ? "Loop projection is unavailable; retaining the last verified projection." : "Loop projection is unavailable." }); } return; } diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index ba0ba069..74676f30 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -49,6 +49,13 @@ test("selected progress remains independent from the sanitized read-only Loop pr assert.equal(left.budget.limits.maxRounds, 3); assert.equal(left.state, "converged"); assert.equal(left.currentNode, "completed"); + const itemId = fixtureItemRef.split("#")[1]; + const scoped = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}&item=${encodeURIComponent(itemId)}`, { method: "GET" }); + assert.equal(scoped.status, 200); + assert.equal(JSON.parse(scoped.body).loopRun.itemRef, fixtureItemRef); + const unknown = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}&item=NOT-ACTIVE`, { method: "GET" }); + assert.equal(unknown.status, 404); + assert.match(JSON.parse(unknown.body).error, /not active/u); assert.deepEqual(left.latestResult, { kind: "approve", summary: "approve" }); for (const result of [left.latestMaker, left.latestCheck, left.latestReviewer]) { assert.equal(typeof result?.summary, "string"); @@ -66,7 +73,10 @@ test("selected progress remains independent from the sanitized read-only Loop pr { from: "converged", outcome: "pass", to: "completed" }, ]); const serialized = JSON.stringify(left); - for (const forbidden of ["invocationId", "lease", "prompt", "route", "authority"]) assert.doesNotMatch(serialized, new RegExp(forbidden, "u")); + assert.equal(left.graph.nodes.find((node) => node.id === "implement").authority, "write"); + assert.equal(left.graph.nodes.find((node) => node.id === "review").authority, "read"); + assert.equal(left.graph.nodes.find((node) => node.id === "verify").capability, "repo-verify"); + for (const forbidden of ["invocationId", "lease", "prompt", "route", "binary", "adapter"]) assert.doesNotMatch(serialized, new RegExp(forbidden, "u")); assert.equal((await httpRequest(baseUrl, url, { method: "POST" })).status, 405); const etag = projection.headers.etag; assert.match(etag, /^W\/"loop-[a-f0-9]{64}"$/u); diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx index 5b30169c..ebc6a0fa 100644 --- a/website/src/content/docs/loops.mdx +++ b/website/src/content/docs/loops.mdx @@ -142,10 +142,45 @@ checklist and ledger under its lifecycle lock, and records an idempotent receipt. Repeating `complete` returns the existing receipt without burning the item twice. -The Checklist page is read-only. For a selected Loop item it exposes the graph -with the current node, attempt and cycle, latest result summary, -ordered transitions, and paused, error, and terminal states. It has no Run or -completion controls. +The canonical Checklist Oven is read-only. Its progress row always follows the +first active checklist item, even while a different item is inspected through +an item deep link (`#`). A current item with a Loop gets a compact +box-drawing graph beside Completion; its active node is highlighted and its +footer legend is derived from the displayed graph. Direct items have no graph, +so the Loop panel collapses and Completion uses the available width. + +Below that row, the Checklist Oven presents three compact columns: completed +events, the remaining item queue, and the inspected item detail. Selecting an +item changes only the detail column. The detail includes the item's action, +completion criterion, validation, file hints, assigned Loop, outcome-labelled +return paths, and a graph-derived node legend. An assigned item without a Run +uses its frozen assignment graph as a preview; an unassigned item is explicitly +labelled as direct execution. Transient stale-snapshot notices float above the +page so refreshes do not reflow or flicker the layout. + +The graph vocabulary is semantic: `S` is Start, a converged terminal is `B` for +Burn, and agent, check, review, and gate symbols come from the actual topology. +For example, the built-in review Loop renders `S`, `I`, `V`, `R`, and `B`; a +custom gate or branch adds its own visible symbol instead of inheriting a +hard-coded legend. The current node remains highlighted without changing graph +geometry. + +The Checklist Oven declares these regions in +`ovens/checklist/checklist.oven` through built-in declarative widgets. It does +not import custom UI or bypass the Oven runtime. It has no Run or completion +controls. Custom Ovens can compose the same core renderer with +`` at the root or +`` inside an item collection. The widget +does not fetch or execute code; the dashboard transport refetches canonical +state after item-keyed Loop invalidations. + +The graph distinguishes writing agents (`[W]`), read-only agents (`[R]`), +deterministic checks, metric or evaluation gates, orchestrators, and terminal +outputs with separate text shapes. For sealed Runs, agent nodes also show the +resolved model and effort from their frozen profile; only this safe display +metadata is projected, never the executable path or private dispatch policy. +Fan-out Loops are arranged as explicit split, parallel branch, merge, and +feedback paths. ## Guarantees and limits diff --git a/website/src/content/docs/ovens/dsl-reference.mdx b/website/src/content/docs/ovens/dsl-reference.mdx index 42eb74f6..013bfd23 100644 --- a/website/src/content/docs/ovens/dsl-reference.mdx +++ b/website/src/content/docs/ovens/dsl-reference.mdx @@ -29,6 +29,7 @@ description: The declarative vocabulary, bindings, controls, and registries for | `` | Refresh state. | | `` | Streaming Diff heading and return link. | | `` / `` / `` | Checklist-specific detail views. | +| `` | Core box-drawing Loop renderer. Bind `source` to a Run projection at the Oven root, or use `@item/loopRun` inside ``. | | `` / `` | Domain selection and its visual-parity explanation. | | `` / `` | Differential Testing headline and log views. | | `` / `` | Differential Testing charts. | @@ -36,6 +37,21 @@ description: The declarative vocabulary, bindings, controls, and registries for `` uses a registered icon and `` supplies literal or bound text. `` supplies a named component property; it is described with bindings below. +`` is core-owned: an Oven may position it but cannot inject renderer +code or perform its own fetch. The dashboard transport supplies fresh canonical +Run projections after item-keyed invalidation events: + +```xml + + + + + + + +``` + ## Interactivity | Element | Purpose | From 5c564d8a04f72406223263b9e6d85d30794bf17e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 24 Jul 2026 18:56:00 +0200 Subject: [PATCH 08/23] fix: simplify checklist loop workspace --- .../ChecklistDashboard/ChecklistDashboard.css | 83 +++++++++++++---- .../ChecklistDashboard.test.mjs | 17 ++-- .../ChecklistDashboard/ChecklistDashboard.tsx | 3 +- .../checklist-dom.golden.html | 2 +- .../checklist-loop-progression.golden.json | 20 ++-- .../checklist-loop-states.golden.json | 32 +++---- dashboard/src/index.css | 30 +----- .../ChecklistWorkspace/ChecklistWorkspace.css | 40 ++++---- .../ChecklistWorkspace/ChecklistWorkspace.tsx | 91 ++++++++++++------- ovens/checklist/checklist.oven | 35 ++++--- skills/burnlist/SKILL.md | 15 ++- .../minimal-review-e2e-dom.golden.json | 20 ++-- src/loops/minimal-review-e2e.test.mjs | 3 +- src/server/burnlist-dashboard-server.mjs | 4 +- website/src/content/docs/loops.mdx | 29 +++--- 15 files changed, 237 insertions(+), 187 deletions(-) diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css index b094dd65..a1524385 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.css @@ -15,24 +15,25 @@ body.checklist-detail-view .dashboard-oven-title { .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-ratio { font-variant-numeric: tabular-nums; } .checklist-detail-shell .checklist-kpi-strip .driving-parity-kpi-progress-donut-segment { transition: stroke-dasharray 160ms ease; } +.checklist-detail-shell #burnlist-detail { + width: min(100%, 1440px); + margin-inline: auto; +} .checklist-detail-shell #burnlist-detail .checklist-progress-workspace { width: 100%; - height: 232px; - min-height: 232px; - max-height: 232px; - flex: 0 0 232px; - grid-template-columns: minmax(220px, 26%) minmax(360px, 48%) minmax(220px, 26%); -} -.checklist-detail-shell #burnlist-detail .checklist-progress-workspace:not(:has(.checklist-current)) { - grid-template-columns: minmax(220px, 30%) minmax(360px, 70%); + height: 260px; + min-height: 260px; + max-height: 260px; + flex: 0 0 260px; + grid-template-columns: minmax(320px, 38%) minmax(0, 62%); } /* The shared Differential Testing shell defaults its top workspace to 310px. */ @media (min-width: 1101px) { body.checklist-detail-view .shell.checklist-detail-shell #burnlist-detail .checklist-overview:not([hidden]) + .checklist-progress-workspace { - height: 232px; - min-height: 232px; - max-height: 232px; + height: 260px; + min-height: 260px; + max-height: 260px; } } @@ -45,11 +46,10 @@ body.checklist-detail-view .dashboard-oven-title { } .checklist-detail-shell .checklist-progress-workspace .event-ledger-panel, -.checklist-detail-shell .checklist-progress-workspace .progress-panel, -.checklist-detail-shell .checklist-progress-workspace .checklist-current { - height: 232px; - min-height: 232px; - max-height: 232px; +.checklist-detail-shell .checklist-progress-workspace .progress-panel { + height: 260px; + min-height: 260px; + max-height: 260px; } .checklist-detail-shell .checklist-progress-workspace .work-panel-body { min-height: 0; } @@ -216,12 +216,55 @@ body.checklist-detail-view .dashboard-oven-title { .event-card:hover .event-card-summary { background: #1c1c1c; } -@media (max-width: 640px) { +/* Keep Progress and Completion side by side through tablet widths. */ +@media (min-width: 521px) and (max-width: 640px) { + body.checklist-detail-view .shell.checklist-detail-shell #burnlist-detail .checklist-overview:not([hidden]) + .checklist-progress-workspace { + height: 260px; + min-height: 260px; + max-height: 260px; + grid-template-columns: minmax(240px, 38%) minmax(0, 62%); + grid-template-rows: minmax(0, 1fr); + } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel { + height: 260px; + min-height: 260px; + max-height: 260px; + grid-column: 1; + grid-row: 1; + } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { + height: 260px; + min-height: 260px; + max-height: 260px; + grid-column: 2; + grid-row: 1; + } +} + +@media (max-width: 520px) { body.checklist-detail-view .dashboard-oven-title { max-width: calc(100% - 190px); font-size: 14px; } .dashboard-detail-time { display: none; } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace { height: auto; min-height: 612px; max-height: none; flex: none; grid-template-rows: 320px 282px; } - .checklist-detail-shell .checklist-progress-workspace .event-ledger-panel, - .checklist-detail-shell .checklist-progress-workspace .progress-panel { height: auto; min-height: 0; max-height: none; } + body.checklist-detail-view .shell.checklist-detail-shell #burnlist-detail .checklist-overview:not([hidden]) + .checklist-progress-workspace { + height: 532px; + min-height: 532px; + max-height: 532px; + flex: none; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 260px 260px; + gap: 12px; + } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace::before { display: none; } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel, + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { + width: 100%; + height: 260px; + min-height: 260px; + max-height: 260px; + border-radius: 8px; + background: var(--card); + } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel { grid-column: 1; grid-row: 1; } + .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { grid-column: 1; grid-row: 2; } .event-card-summary { grid-template-columns: max-content minmax(0, 1fr) max-content; padding: 0 10px; gap: 8px; } .event-card-description { padding: 0 10px 8px; } .event-card-field { grid-template-columns: 1fr; gap: 3px; } diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index 612c837a..e766f053 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -13,9 +13,12 @@ const stylesheetPath = new URL("./ChecklistDashboard.css", import.meta.url).path const libPath = new URL("../../lib", import.meta.url).pathname; const ovenPath = new URL("../../oven", import.meta.url).pathname; -test("checklist progress owns its workspace height instead of inheriting the differential default", async () => { +test("checklist keeps a centered horizontal KPI strip above matched Progress and Completion panels", async () => { const stylesheet = await readFile(stylesheetPath, "utf8"); - assert.match(stylesheet, /body\.checklist-detail-view \.shell\.checklist-detail-shell #burnlist-detail \.checklist-overview:not\(\[hidden\]\) \+ \.checklist-progress-workspace \{\s+height: 232px;\s+min-height: 232px;\s+max-height: 232px;/u); + assert.match(stylesheet, /\.checklist-detail-shell #burnlist-detail \{\s+width: min\(100%, 1440px\);\s+margin-inline: auto;/u); + assert.match(stylesheet, /\.checklist-detail-shell #burnlist-detail \.checklist-progress-workspace \{[^}]+grid-template-columns: minmax\(320px, 38%\) minmax\(0, 62%\);/su); + assert.match(stylesheet, /@media \(min-width: 521px\) and \(max-width: 640px\) \{[\s\S]+grid-template-columns: minmax\(240px, 38%\) minmax\(0, 62%\);[\s\S]+\.event-ledger-panel \{[\s\S]+grid-column: 1;[\s\S]+grid-row: 1;[\s\S]+\.progress-panel \{[\s\S]+grid-column: 2;[\s\S]+grid-row: 1;/u); + assert.match(stylesheet, /@media \(max-width: 520px\) \{[\s\S]+\.event-ledger-panel,[\s\S]+\.progress-panel \{\s+width: 100%;/u); }); test("checklist detail renders the split progress surface and event card list", async () => { @@ -46,13 +49,13 @@ test("checklist detail renders the split progress surface and event card list", assert.doesNotMatch(markup, /aria-label="Burnlist progress chart view"/u); assert.match(markup, /Age<\/span>Event<\/span>Result<\/span>Delta<\/span>Done<\/span>/u); assert.match(markup, /class="checklist-workspace"/u); - assert.match(markup, /aria-label="Completed events"/u); - assert.match(markup, /aria-label="Remaining items"/u); - assert.match(markup, /class="checklist-workspace__empty">No active item/u); - assert.equal((markup.match(/class="checklist-workspace__event"/gu) ?? []).length, 2); + assert.match(markup, /aria-label="All items"/u); + assert.match(markup, /aria-label="Item B2 detail"/u); + assert.match(markup, /Item detail<\/span>Completed<\/span>/u); + assert.equal((markup.match(/class="checklist-workspace__item is-completed/gu) ?? []).length, 2); assert.equal(markup.indexOf("Second event") < markup.indexOf("First event"), true); assert.doesNotMatch(markup, /data-event-card="true"/u); - assert.doesNotMatch(markup, /Completed: 2026/u); + assert.equal((markup.match(/
Completed<\/dt>/gu) ?? []).length, 1); assert.doesNotMatch(markup, />DONE]*>Changes<\/button>/u); assert.doesNotMatch(markup, /Burnlist detail view/u); diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index 4a95ca0d..80dba5a5 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -6,7 +6,6 @@ export { checklistEventDetailFields } from "@lib/checklist-adapter"; import "./ChecklistDashboard.css"; import { buildChecklistProgressChart, KpiItem, KpiStrip, LogTable, ProgressDonut, SectionHeader } from "@oven"; import { LoopGraph } from "@/components/LoopGraph"; -import { ChecklistCurrent } from "@/oven/ChecklistCurrent"; import { ChecklistWorkspace } from "@/oven/ChecklistWorkspace"; function ChecklistKpis({ data }: { data: ChecklistProgressData }) { @@ -111,5 +110,5 @@ export function ChecklistDashboard({ data }: { data: ChecklistProgressData }) { document.body.classList.add("driving-parity-view", "checklist-detail-view"); return () => document.body.classList.remove("driving-parity-view", "checklist-detail-view"); }, []); - return
; + return
; } diff --git a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html index ae9fd828..b8093829 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html +++ b/dashboard/src/components/ChecklistDashboard/checklist-dom.golden.html @@ -1 +1 @@ -
Current
Complete
Progress
2·2(100%)
Elapsed
10m
Avg pace
5m
Time left
0m
Progress
AgeEventResultDeltaDone
10mB2Done+1100%
20mB1Done+150%
Completion
25%50%75%100%11:4211:4411:4611:48Completed 1 itemCompleted 1 item
Events2
B2Second event10m · 100%
B1First event20m · 50%
Items0

No active item

+
Current
Complete
Progress
2·2(100%)
Elapsed
10m
Avg pace
5m
Time left
0m
Progress
AgeEventResultDeltaDone
10mB2Done+1100%
20mB1Done+150%
Completion
25%50%75%100%11:4211:4411:4611:48Completed 1 itemCompleted 1 item
Items2
Item detailCompleted

B2 · Second event

Status
Completed
Completed
Changed
src/second.mjs
Proof
node --test second.test.mjs
Outcome
Second proof.
Follow-up
None.
diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json index 2b0d3d77..6b86a6c7 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-progression.golden.json @@ -3,35 +3,35 @@ "checkpoint": "implement/1/reject", "projectionBytes": 2157, "projectionSha256": "6e8e57daad385abd423cc1849253359d322d7253db5bfc788cea632c64b2cc4a", - "domBytes": 10215, - "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + "domBytes": 9373, + "domSha256": "c80e10ebd2a0bede8978a933944f0a020baba40ea27815749b303bfc57ffd252" }, { "checkpoint": "implement/2/reject", "projectionBytes": 2157, "projectionSha256": "f0f0bc7fad845f804893d6b193c80926a2245b8174a6e7787eb9ded01f6069e7", - "domBytes": 10215, - "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + "domBytes": 9373, + "domSha256": "c80e10ebd2a0bede8978a933944f0a020baba40ea27815749b303bfc57ffd252" }, { "checkpoint": "review/2/approve", "projectionBytes": 2292, "projectionSha256": "dfc91fde48e5a690f5d7974b1320828376d9dac73a92f96a835bceb3a3186355", - "domBytes": 10212, - "domSha256": "dad8893e2e1194cf690929501441b9182678f664a97bea9375c15fea698ec98b" + "domBytes": 9373, + "domSha256": "051bc5760c63e3dc8532e84778dc2b31d87b7674a45f0dbae9ab4c13fb2adea8" }, { "checkpoint": "converged/1/approve", "projectionBytes": 2364, "projectionSha256": "9985718fe0bda762ac030d9d282ac51682819e53dcd9947039ad2ba2a4e84a9f", - "domBytes": 10215, - "domSha256": "f27683bf4be303b13cbff2a7dbab54535076f506ed109e2e11d3952b92a0aa80" + "domBytes": 9373, + "domSha256": "051bc5760c63e3dc8532e84778dc2b31d87b7674a45f0dbae9ab4c13fb2adea8" }, { "checkpoint": "completed/1/approve", "projectionBytes": 2435, "projectionSha256": "638a6ba72885ebf7f130bb2dc31fd1d7a685eea85b5b9e0a1d7eb84c6f31c424", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" } ] diff --git a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json index 7d0c7a88..0869c825 100644 --- a/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json +++ b/dashboard/src/components/ChecklistDashboard/checklist-loop-states.golden.json @@ -1,42 +1,42 @@ [ { "checkpoint": "paused", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "failed", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "stopped", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "needs-human", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "exhausted", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "stale", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "corrupt", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "completed", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" } ] diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 107d32f6..818113fd 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -383,7 +383,7 @@ body.checklist-detail-view .dashboard-oven-title { .burnlist-table-cell-project { padding-top: 13px; } } -@media (max-width: 1100px) { +@media (max-width: 520px) { .checklist-detail-shell #burnlist-detail .checklist-progress-workspace { height: auto; min-height: 0; @@ -439,34 +439,6 @@ body.checklist-detail-view .dashboard-oven-title { min-height: 52px; padding: 0 8px; } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace, - .shell.checklist-detail-shell #burnlist-detail .checklist-kpi-strip:not([hidden]) + .checklist-progress-workspace { - height: auto; - min-height: 0; - max-height: none; - flex: none; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto 200px; - gap: 12px; - margin-top: 12px; - } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel, - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { - width: 100%; - height: auto; - max-height: none; - padding: 0 12px 12px; - border-radius: 8px; - background: var(--card); - } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .event-ledger-panel { min-height: 112px; grid-column: 1; grid-row: 1; } - .checklist-detail-shell #burnlist-detail .checklist-progress-workspace .progress-panel { height: 200px; grid-column: 1; grid-row: 2; } - .checklist-detail-shell .event-ledger-panel .work-panel-body { padding: 0; } - .checklist-detail-shell .event-ledger-panel .checklist-log { padding-right: 0; } - .checklist-detail-shell .event-ledger-panel .checklist-log-table-header, - .checklist-detail-shell .event-ledger-panel .log-row.log-table-row { font-size: 12px; } - .checklist-detail-shell .event-ledger-panel .checklist-log-table-header span + span, - .checklist-detail-shell .event-ledger-panel .log-row.log-table-row .log-table-cell + .log-table-cell { padding-left: 6px; } } @media (max-width: 520px) { diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css index f428b74e..5eb5c676 100644 --- a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.css @@ -1,11 +1,11 @@ .checklist-workspace { display: grid; width: 100%; - min-height: 330px; + min-height: 420px; margin-top: 12px; border-top: 1px solid rgba(168, 168, 168, .15); border-bottom: 1px solid rgba(168, 168, 168, .1); - grid-template-columns: minmax(220px, 26%) minmax(250px, 30%) minmax(360px, 44%); + grid-template-columns: minmax(280px, 36%) minmax(420px, 64%); } .checklist-workspace__column { min-width: 0; background: #111; } @@ -21,19 +21,7 @@ justify-content: space-between; } -.checklist-workspace__event-list, -.checklist-workspace__item-list { display: block; max-height: 390px; overflow: auto; } -.checklist-workspace__event { - display: grid; - min-height: 42px; - padding: 0 12px; - border-bottom: 1px solid rgba(163, 172, 183, .07); - grid-template-columns: 3ch minmax(0, 1fr) max-content; - align-items: center; - gap: 8px; -} -.checklist-workspace__event-id { color: rgba(97, 211, 148, .78); font-size: 12px; } -.checklist-workspace__event-title, +.checklist-workspace__item-list { display: block; max-height: 520px; overflow: auto; } .checklist-workspace__item-copy span { min-width: 0; overflow: hidden; @@ -60,13 +48,29 @@ .checklist-workspace__item:focus-visible { background: rgba(255, 255, 255, .025); outline: none; } .checklist-workspace__item.is-selected { background: rgba(90, 162, 255, .07); box-shadow: inset 2px 0 rgba(90, 162, 255, .7); } .checklist-workspace__item-marker { color: rgba(168, 168, 168, .42); font-size: 11px; text-align: center; } -.checklist-workspace__item:first-child .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item.is-current .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item.is-completed { opacity: .64; } +.checklist-workspace__item.is-completed .checklist-workspace__item-marker { color: var(--status-green); } +.checklist-workspace__item.is-completed.is-selected { opacity: 1; } .checklist-workspace__item-copy { display: grid; min-width: 0; gap: 3px; } .checklist-workspace__item-copy b { color: rgba(232, 232, 232, .86); font-size: 12px; font-weight: 500; } +.checklist-workspace__divider { + display: flex; + height: 30px; + padding: 0 12px; + border-top: 1px solid rgba(163, 172, 183, .1); + border-bottom: 1px solid rgba(163, 172, 183, .07); + color: rgba(168, 168, 168, .42); + font-size: 10px; + align-items: center; + justify-content: space-between; + text-transform: uppercase; +} .checklist-workspace__detail-body { padding: 14px 16px 12px; } .checklist-workspace__detail-title { display: grid; grid-template-columns: 3ch minmax(0, 1fr); gap: 8px; } .checklist-workspace__detail-title > span { color: var(--status-green); font-size: 12px; } +.checklist-workspace__detail-title.is-completed { grid-template-columns: 3ch minmax(0, 1fr); } .checklist-workspace__detail-title h2 { margin: 0; color: rgba(232, 232, 232, .9); font: 14px/1.3 var(--dashboard-font); } .checklist-workspace__fields { display: grid; margin: 12px 0 0; gap: 8px; } .checklist-workspace__fields > div { display: grid; grid-template-columns: 8ch minmax(0, 1fr); gap: 10px; } @@ -99,9 +103,7 @@ .checklist-workspace__empty { margin: 0; padding: 16px; color: rgba(168, 168, 168, .5); font-size: 12px; } @media (max-width: 900px) { - .checklist-workspace { grid-template-columns: minmax(220px, 38%) minmax(0, 62%); } - .checklist-workspace__events { grid-column: 1 / -1; grid-row: 2; border-top: 1px solid rgba(163, 172, 183, .1); } - .checklist-workspace__events .checklist-workspace__event-list { max-height: 210px; } + .checklist-workspace { grid-template-columns: minmax(240px, 40%) minmax(0, 60%); } } @media (max-width: 620px) { diff --git a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx index a0895c8e..2bbdcbed 100644 --- a/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx +++ b/dashboard/src/oven/ChecklistWorkspace/ChecklistWorkspace.tsx @@ -1,10 +1,20 @@ -import type { ChecklistItem, ChecklistProgressData, LoopRunProjection } from "@lib"; -import { compactAge, eventRows } from "@lib/checklist-adapter"; +import type { ChecklistItem, ChecklistProgressData, CompletedItem, LoopRunProjection } from "@lib"; +import { checklistEventDetailFields, compactAge, eventRows } from "@lib/checklist-adapter"; import { itemTopologyProjection, LoopCompact, LoopLegend } from "@/components/LoopGraph"; import "./ChecklistWorkspace.css"; -function inspectedItem(data: ChecklistProgressData) { - return data.active.find((item) => item.id === data.selectedItemId) ?? data.active[0] ?? null; +type ItemSelection = + | { status: "active"; item: ChecklistItem; index: number } + | { status: "completed"; item: CompletedItem; index: number }; + +function inspectedItem(data: ChecklistProgressData): ItemSelection | null { + const activeIndex = data.active.findIndex((item) => item.id === data.selectedItemId); + if (activeIndex >= 0) return { status: "active", item: data.active[activeIndex], index: activeIndex }; + const completed = eventRows(data); + const completedIndex = completed.findIndex((item) => item.id === data.selectedItemId); + if (completedIndex >= 0) return { status: "completed", item: completed[completedIndex], index: completedIndex }; + if (data.active[0]) return { status: "active", item: data.active[0], index: 0 }; + return completed[0] ? { status: "completed", item: completed[0], index: 0 } : null; } function previewRun(item: ChecklistItem, data: ChecklistProgressData): LoopRunProjection { @@ -35,34 +45,30 @@ function previewRun(item: ChecklistItem, data: ChecklistProgressData): LoopRunPr }; } -function EventsColumn({ data }: { data: ChecklistProgressData }) { - const rows = eventRows(data).slice(0, 10); - return
-
Events{rows.length}
-
- {rows.map((item) =>
- {item.id} - {item.title} - {compactAge(item.completedAt, data.generatedAt)} · {item.percent}% -
)} - {!rows.length &&

No completed events

} -
-
; -} - -function ItemsColumn({ data, selected }: { data: ChecklistProgressData; selected: ChecklistItem | null }) { - return
-
Items{data.active.length}
+function ItemsColumn({ data, selected }: { data: ChecklistProgressData; selected: ItemSelection | null }) { + const completed = eventRows(data); + return
+
Items{data.total}
; } @@ -74,17 +80,14 @@ const detailFields = [ ["Files", "Files/search"], ] as const; -function DetailColumn({ data, item }: { data: ChecklistProgressData; item: ChecklistItem | null }) { - if (!item) return

No active item

; +function ActiveDetail({ data, item }: { data: ChecklistProgressData; item: ChecklistItem }) { const run = previewRun(item, data); const topology = item.loop?.graph ? itemTopologyProjection(run) : null; const legendSymbols = topology ? { start: "S", ...Object.fromEntries(topology.graph.nodes.filter((node) => node.kind === "terminal" && node.terminalState === "converged").map((node) => [node.id, "B"])), } : undefined; - return
-
Item detail{item.id}
-
+ return
{item.id}

{item.title}

{detailFields.map(([label, key]) => item.fields[key] ?
{label}
{item.fields[key]}
: null)}
{item.loop ?
@@ -92,15 +95,35 @@ function DetailColumn({ data, item }: { data: ChecklistProgressData; item: Check {topology && }
:
Direct implementation · no Loop assigned
} -
+
; +} + +function CompletedDetail({ item }: { item: CompletedItem }) { + const fields = checklistEventDetailFields(item.detail).filter((field) => field.label !== "Completed" && field.values.length); + return
+

{item.id} · {item.title}

+
+
Status
Completed
+
Completed
+ {fields.map((field) =>
{field.label === "Detail" ? "Outcome" : field.label}
{field.values.join(" · ")}
)} +
+
; +} + +function DetailColumn({ data, selected }: { data: ChecklistProgressData; selected: ItemSelection | null }) { + const status = !selected ? "Empty" : selected.status === "completed" ? "Completed" : selected.index === 0 ? "Current" : "Pending"; + return
+
Item detail{status}
+ {!selected ?

No items

+ : selected.status === "active" ? + : }
; } export function ChecklistWorkspace({ data }: { data: ChecklistProgressData }) { const selected = inspectedItem(data); return
- - +
; } diff --git a/ovens/checklist/checklist.oven b/ovens/checklist/checklist.oven index 34a74272..d378765a 100644 --- a/ovens/checklist/checklist.oven +++ b/ovens/checklist/checklist.oven @@ -1,16 +1,23 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index f4c73abe..5bebcd43 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -204,14 +204,13 @@ Run lease. For live foreground work, the owner handles Ctrl-C: first interrupt requests pause after its child exits, second requests controlled stop. Do not claim that a separate CLI invocation can take over a live Run. -The canonical Checklist Oven keeps the progress-row Loop bound to the first -active item; inspecting another `#` changes only the three-column -Events / Items / Item detail workspace below it. A Loop-assigned current item -shows its compact topology beside Completion with the active node highlighted -and a graph-derived legend; a direct current item collapses that panel. Item -detail shows the selected item's contract, Loop preview or live Run, labelled -return paths, and a graph-derived node legend. Ovens may compose Burnlist's -core box-drawing renderer declaratively with +The canonical Checklist Oven keeps a centered KPI row above the side-by-side +Progress ledger and Completion trend. Its unified Items list contains current, +pending, and completed work; inspecting another `#` changes only the +adjacent Item detail. That detail shows the selected active item's contract, +Loop preview or live Run, labelled return paths, and graph-derived node legend, +or a completed item's recorded timestamp and outcome. Direct items show no +Loop graph. Ovens may compose Burnlist's core box-drawing renderer declaratively with ``, or with `` inside a collection. The widget never fetches, executes, or imports custom code; core transport refreshes it after diff --git a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json index c6ccc098..e39c9e23 100644 --- a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json +++ b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json @@ -1,27 +1,27 @@ [ { "checkpoint": "needs-human", - "domBytes": 10217, - "domSha256": "716f2668931ac3f80620c2c13cabed72a9b504383c6d5cb7d08daddf9faa013c" + "domBytes": 9373, + "domSha256": "051bc5760c63e3dc8532e84778dc2b31d87b7674a45f0dbae9ab4c13fb2adea8" }, { "checkpoint": "paused", - "domBytes": 10215, - "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + "domBytes": 9373, + "domSha256": "c80e10ebd2a0bede8978a933944f0a020baba40ea27815749b303bfc57ffd252" }, { "checkpoint": "repair", - "domBytes": 10215, - "domSha256": "133792d445f5283fbe6c5d5385cec4402740cef7b937b179c1d44f5f5db66497" + "domBytes": 9373, + "domSha256": "c80e10ebd2a0bede8978a933944f0a020baba40ea27815749b303bfc57ffd252" }, { "checkpoint": "converged", - "domBytes": 10215, - "domSha256": "edb3d07570bf440ec273decce27b5acb31c458247ab4193426b72546f1fd089c" + "domBytes": 9373, + "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" }, { "checkpoint": "post-completion", - "domBytes": 8510, - "domSha256": "e66102e9fc69180db39b08ba34c490b2793216d1cbe102df0c44aa52619337de" + "domBytes": 8982, + "domSha256": "bdc604a3d9b34b5e642df92de34205921437f1ffbfc4b10050b18681662c6aae" } ] diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs index b267ea7b..8dfd7adc 100644 --- a/src/loops/minimal-review-e2e.test.mjs +++ b/src/loops/minimal-review-e2e.test.mjs @@ -96,7 +96,8 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; assert.deepEqual(escalationProjection, escalationInspection); const needsHumanUi = render("needs-human", escalationProjection); - assert.match(needsHumanUi.dom, /
/u); + assert.match(needsHumanUi.dom, /
/u); + assert.match(needsHumanUi.dom, /
; } diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.css b/dashboard/src/oven/LoopProgress/LoopProgress.css new file mode 100644 index 00000000..555a56e8 --- /dev/null +++ b/dashboard/src/oven/LoopProgress/LoopProgress.css @@ -0,0 +1,78 @@ +.loop-progress { + display: grid; + gap: 14px; + padding: 20px; + color: var(--foreground, #e8edf5); +} +.loop-progress__now, +.loop-progress__context article, +.loop-progress__loop, +.loop-progress__map, +.loop-progress__activity { + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--card, #10151d) 92%, transparent); +} +.loop-progress__now { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: baseline; + gap: 14px; + padding: 14px 16px; +} +.loop-progress__now > span, +.loop-progress__context-head > span, +.loop-progress__context span, +.loop-progress h2 { + color: var(--muted-foreground, #92a0b3); + font-size: 11px; + font-weight: 800; + letter-spacing: .12em; +} +.loop-progress__now strong { overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; } +.loop-progress__context-head { display: flex; align-items: baseline; gap: 10px; padding: 0 3px; } +.loop-progress__context-head strong { font-size: 13px; } +.loop-progress__now small, .loop-progress h2 small { color: var(--muted-foreground, #92a0b3); font-weight: 500; letter-spacing: 0; } +.loop-progress__context { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 10px; } +.loop-progress__context article { min-width: 0; padding: 12px 14px; } +.loop-progress__context p { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 13px; line-height: 1.35; } +.loop-progress__work { display: grid; gap: 12px; } +.loop-progress__loop, .loop-progress__map { min-width: 0; padding: 14px; } +.loop-progress h2 { margin: 0 0 12px; } +.loop-progress__map ol { display: flex; align-items: stretch; margin: 0; padding: 0; list-style: none; } +.loop-progress__map li { display: flex; flex: 1; align-items: center; min-width: 0; color: var(--muted-foreground, #92a0b3); font-size: 12px; } +.loop-progress__map li span { position: relative; display: grid; flex: 1; gap: 4px; min-height: 68px; align-content: center; padding: 10px 6px; border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 7px; text-align: center; } +.loop-progress__map li span > strong { color: var(--foreground, #e8edf5); font-size: 12px; } +.loop-progress__map li span > small { font-size: 10px; } +.loop-progress__map li span > em { font-size: 8px; font-style: normal; font-weight: 800; letter-spacing: .08em; } +.loop-progress__map li b { padding: 0 3px; font-weight: 400; } +.loop-progress__map li.is-active span { border-color: #67d7a4; background: color-mix(in srgb, #67d7a4 17%, transparent); color: #b9f5d8; font-weight: 750; } +.loop-progress__map li.is-running span { box-shadow: inset 0 0 0 2px #76a9fa; } +.loop-progress__map li.is-running span > em:first-of-type { color: #9bc1ff; } +.loop-progress__empty { margin: 20px 0; color: var(--muted-foreground, #92a0b3); text-align: center; } +.loop-progress__activity { min-width: 0; padding: 14px; } +.loop-progress__activity ol { display: grid; block-size: 150px; margin: 0; padding: 0; gap: 0; overflow: hidden; list-style: none; } +.loop-progress__activity li { display: grid; min-width: 0; min-height: 15px; grid-template-columns: 12ch minmax(0, 1fr) auto; gap: 8px; color: var(--muted-foreground, #92a0b3); font-size: 11px; line-height: 15px; } +.loop-progress__activity li b { color: #9bc1ff; font-weight: 700; } +.loop-progress__activity li span { overflow: hidden; color: var(--foreground, #e8edf5); text-overflow: ellipsis; white-space: nowrap; } +.loop-progress__activity li small { color: var(--muted-foreground, #92a0b3); } +.loop-progress__activity-empty { display: block !important; color: var(--muted-foreground, #92a0b3); } +.loop-progress__observed { display: grid; min-width: 0; grid-template-columns: 10ch minmax(0, 1fr); gap: 8px; margin-top: 8px; color: var(--muted-foreground, #92a0b3); font-size: 11px; } +.loop-progress__observed b { color: #a9d1ff; font-weight: 700; letter-spacing: .08em; } +.loop-progress__observed span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.loop-progress footer { overflow-wrap: anywhere; color: var(--muted-foreground, #92a0b3); font-size: 11px; line-height: 1.6; } +.loop-progress footer > span { font-weight: 800; letter-spacing: .08em; } +@media (max-width: 900px) { + .loop-progress__context { grid-template-columns: 1fr 1fr; } + .loop-progress__work { grid-template-columns: 1fr; } +} +@media (max-width: 620px) { + .loop-progress { padding: 12px; } + .loop-progress__now { grid-template-columns: auto 1fr; } + .loop-progress__now small { grid-column: 2; } + .loop-progress__context { grid-template-columns: 1fr; } + .loop-progress__map ol { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } + .loop-progress__map li b { display: none; } + .loop-progress__activity li { grid-template-columns: 10ch minmax(0, 1fr); } + .loop-progress__activity li small { display: none; } +} diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.test.ts b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts new file mode 100644 index 00000000..fe13f511 --- /dev/null +++ b/dashboard/src/oven/LoopProgress/LoopProgress.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { LoopProgress } from "./LoopProgress"; + +const graph = { + entry: "make", + nodes: [ + { id: "make", kind: "agent", role: "Maker" }, + { id: "review", kind: "agent", role: "Review" }, + ], + edges: [{ from: "make", on: "complete", to: "review" }], +}; + +function fixture(selectedItemId: string) { + return { + generatedAt: "2026-07-24T20:00:00Z", + repoKey: "0123456789ab", + title: "Progress", + repo: "burnlist", + planLabel: "inprogress", + selectedItemId, + total: 2, done: 0, remaining: 2, percent: 0, warnings: [], completed: [], history: [], + active: [ + { id: "O0", title: "Seed Oven", fields: { Action: "Show the work simply.", "Files/search": "ovens/ dashboard/src/oven/" }, loop: null }, + { id: "H3", title: "Accept reports", fields: { Action: "Bind results safely.", "Files/search": "src/loops/run/" }, + loop: { selector: "loop:builtin:review", assignmentId: "a", executionRevision: "e", packageRevision: "p", graph } }, + ], + loopRun: { + schema: "burnlist-loop-read-projection@1", runId: "run-1", itemRef: "item:260724-002#O0", + loopId: "loop:builtin:review", loopRevision: null, createdAt: 1, updatedAt: 2, + state: "running", currentNode: "review", attempt: 1, cycle: 0, revision: "r", + budget: { limits: { maxRounds: 1, maxMinutes: 1, maxAgentRuns: 1, maxCheckRuns: 1, maxTransitions: 1, maxOutputBytes: 1 }, + counters: { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }, + elapsedMilliseconds: 1, journal: { maximum: 1, used: 0, remaining: 1 } }, + latestResult: null, graph, transitions: [], + }, + } as any; +} + +test("shows low-text canonical context and never fabricates hook activity", () => { + const markup = renderToStaticMarkup(createElement(LoopProgress, { data: fixture("O0") })); + for (const label of ["NOW", "WHY", "SYSTEM", "FILES", "HOOKS"]) assert.match(markup, new RegExp(`>${label}<`, "u")); + for (const label of ["LOOP", "SYSTEM FLOW"]) assert.match(markup, new RegExp(`>${label} `, "u")); + assert.match(markup, />O0 · ReviewObserver { + const markup = renderToStaticMarkup(createElement(LoopProgress, { data: fixture("H3") })); + assert.match(markup, />O0 · ReviewCONTEXTH3 · Accept reportsValidate \+ review<\/strong>/u); + assert.match(markup, /
  • Agent \+ workspace<\/strong>/u); + assert.match(markup, /class="is-active"/u); +}); + +test("technical host-report actions become simple subsystem WHY text", () => { + const data = fixture("H3"); + data.active[1].fields.Action = "Validate an external report against its durable claim, rederive candidate identity, append the result exactly once, and select a declared edge."; + const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); + assert.match(markup, /Keeps each step controlled and verifiable/u); + assert.doesNotMatch(markup, /durable claim|candidate identity|declared edge/u); +}); + +test("renders only bounded provenance-labelled sanitized activity", () => { + const data = fixture("O0"); + data.loopRun.activity = { hooks: "available", records: Array.from({ length: 12 }, (_, index) => ({ + at: index, origin: index % 2 ? "host-hook" : "agent-reported", kind: index % 2 ? "subagent-started" : "tool-finished", + nodeId: "review", attempt: 1, provider: index % 2 ? "claude" : "codex", subagentId: index % 2 ? `child-${index}` : undefined, + tool: index % 2 ? undefined : "node-test", observedPath: index % 2 ? `src/subagents/${index}.mjs` : "src/loops/run/binder.mjs", truncated: index === 11, + })) }; + const markup = renderToStaticMarkup(createElement(LoopProgress, { data })); + assert.match(markup, />ACTIVITY HOOKS<\/span>

    available10 recentOBSERVED.*src\/loops\/run\/binder\.mjs/u); + assert.equal((markup.match(/class="loop-progress__activity"/gu) ?? []).length, 1); + assert.equal((markup.match(/subagent child-/gu) ?? []).length + (markup.match(/node-test/gu) ?? []).length, 10); +}); + +test("activity viewport has a stable ten-row height", async () => { + const css = await readFile("dashboard/src/oven/LoopProgress/LoopProgress.css", "utf8"); + assert.match(css, /\.loop-progress__activity ol \{[^}]*block-size: 150px;[^}]*overflow: hidden;/su); + assert.match(css, /\.loop-progress__activity li \{[^}]*min-height: 15px;[^}]*line-height: 15px;/su); +}); diff --git a/dashboard/src/oven/LoopProgress/LoopProgress.tsx b/dashboard/src/oven/LoopProgress/LoopProgress.tsx new file mode 100644 index 00000000..99c00915 --- /dev/null +++ b/dashboard/src/oven/LoopProgress/LoopProgress.tsx @@ -0,0 +1,139 @@ +import type { ChecklistItem, ChecklistProgressData, LoopRunProjection } from "@lib"; +import { LoopCompact } from "@/components/LoopGraph"; +import "./LoopProgress.css"; + +const architecture = [ + { id: "plan", label: "Plan", detail: "item contract", hint: ["notes/burnlists", "plan-model"], why: "Keeps the work clear and ordered." }, + { id: "loops", label: "Loop control", detail: "claim · route", hint: ["src/loops", ".burnlist/loops", "LoopGraph", "src/cli/loop"], why: "Keeps each step controlled and verifiable." }, + { id: "work", label: "Agent + workspace", detail: "make the change", hint: ["adapters", "workspace", "provider"], why: "Gives the work a bounded place to happen." }, + { id: "proof", label: "Validate + review", detail: "prove · decide", hint: ["validate", "review", "test", "capabilities"], why: "Checks the result before it can move forward." }, + { id: "burn", label: "Burn", detail: "complete item", hint: ["completion", "lifecycle"], why: "Finishes work only after its proof is complete." }, + { id: "observe", label: "Observer", detail: "Oven · events", hint: ["ovens/", "src/ovens", "src/server", "dashboard/src/oven", "src/events", "hooks", "streaming-diff"], why: "Makes truthful progress easy to see." }, +] as const; + +function selectedItem(data: ChecklistProgressData) { + return data.active.find((item) => item.id === data.selectedItemId) ?? data.active[0] ?? null; +} + +function subsystem(item: ChecklistItem | null) { + const surface = `${item?.fields["Files/search"] ?? ""} ${item?.title ?? ""}`.toLowerCase(); + let best = architecture[0]; + let score = 0; + for (const candidate of architecture) { + const matches = candidate.hint.filter((hint) => surface.includes(hint.toLowerCase())).length; + if (matches > score) { + best = candidate; + score = matches; + } + } + return best; +} + +function plainWhy(item: ChecklistItem | null, system: (typeof architecture)[number]) { + return item ? system.why : "No active work remains."; +} + +function preview(item: ChecklistItem): LoopRunProjection | null { + if (!item.loop) return null; + return { + schema: "burnlist-loop-read-projection@1", + runId: "preview", + itemRef: `item:preview#${item.id}`, + loopId: item.loop.selector, + loopRevision: null, + createdAt: 0, + updatedAt: 0, + state: "prepared", + currentNode: item.loop.graph?.entry ?? "start", + attempt: 0, + cycle: 0, + revision: "preview", + budget: { + limits: { maxRounds: 0, maxMinutes: 0, maxAgentRuns: 0, maxCheckRuns: 0, maxTransitions: 0, maxOutputBytes: 0 }, + counters: { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }, + elapsedMilliseconds: 0, + journal: { maximum: 0, used: 0, remaining: 0 }, + }, + latestResult: null, + graph: item.loop.graph ?? { entry: "start", nodes: [], edges: [] }, + transitions: [], + }; +} + +function displayNode(run: LoopRunProjection | null | undefined) { + if (!run) return "Direct work"; + const node = run.graph.nodes.find((candidate) => candidate.id === run.currentNode); + return node?.role ?? node?.capability ?? run.currentNode; +} + +function runItemId(run: LoopRunProjection) { + const marker = run.itemRef.lastIndexOf("#"); + return marker >= 0 ? run.itemRef.slice(marker + 1) : run.itemRef; +} + +function runSubsystem(run: LoopRunProjection | null) { + if (!run) return null; + const node = run.graph.nodes.find((candidate) => candidate.id === run.currentNode); + const meaning = `${run.currentNode} ${node?.role ?? ""} ${node?.capability ?? ""}`; + if (node?.kind === "check" || node?.kind === "gate" || /review|verify|validate/u.test(meaning)) return "proof"; + if (node?.kind === "agent") return "work"; + if (node?.kind === "terminal" || /converg|complete|burn/u.test(run.currentNode)) return "burn"; + return "loops"; +} + +function activityText(record: NonNullable["records"][number]) { + const where = record.nodeId ? `${record.nodeId}${record.attempt ? ` #${record.attempt}` : ""}` : "Run"; + const detail = record.subagentId ? `subagent ${record.parentAgentId ? `${record.parentAgentId} / ` : ""}${record.subagentId}` + : record.tool ?? record.capability ?? record.outcome ?? record.state ?? ""; + return [where, record.kind.replaceAll("-", " "), detail].filter(Boolean).join(" · "); +} + +export function LoopProgress({ data }: { data: ChecklistProgressData }) { + const item = selectedItem(data); + const system = subsystem(item); + const authoritativeRun = data.loopRun ?? null; + const itemRun = item && authoritativeRun?.itemRef.endsWith(`#${item.id}`) ? authoritativeRun : item ? preview(item) : null; + const files = item?.fields["Files/search"] ?? "No declared file surface"; + const runningSystem = runSubsystem(authoritativeRun); + const activity = authoritativeRun?.activity; + const recentActivity = activity?.records.slice(-10) ?? []; + const observedPaths = [...new Set(recentActivity.flatMap((record) => record.observedPath ? [record.observedPath] : []))]; + return

    +
    + NOW + {authoritativeRun ? `${runItemId(authoritativeRun)} · ${displayNode(authoritativeRun)}` : item ? `${item.id} · ${item.title}` : "Complete"} + {authoritativeRun ? `Run · ${authoritativeRun.state}` : "Canonical checklist"} +
    + +
    CONTEXT{item ? `${item.id} · ${item.title}` : "None"}
    +
    +
    WHY

    {plainWhy(item, system)}

    +
    SYSTEM

    {system.label}

    +
    HOOKS

    {activity?.hooks ?? "Unavailable"}

    +
    + +
    +
    +

    SYSTEM FLOW whole Burnlist

    +
      + {architecture.map((part, index) =>
    1. + {part.label}{part.detail}{part.id === runningSystem && NOW}{part.id === system.id && CONTEXT}{index < architecture.length - 1 && } +
    2. )} +
    +
    +
    +

    LOOP {item?.loop?.selector ?? "direct"}

    + {itemRun ? + :

    No Loop assigned

    } +
    +
    +
    +

    ACTIVITY {activity ? `${recentActivity.length} recent` : "unavailable"}

    +
      {recentActivity.length ? recentActivity.slice().reverse().map((record, index) =>
    1. + {record.origin}{activityText(record)}{record.provider ? {record.provider} : null}{record.truncated ? truncated : null} +
    2. ) :
    3. No observed hook activity. Runner state remains canonical.
    4. }
    +
    OBSERVED{observedPaths.length ? observedPaths.join(" · ") : "Unavailable"}
    +
    +
    FILES {files}
    Selected · {item ? `${item.id} ${item.title}` : "none"}{authoritativeRun && item && !authoritativeRun.itemRef.endsWith(`#${item.id}`) ? " · Run remains authoritative for another item" : ""}
    +
    ; +} diff --git a/dashboard/src/oven/LoopProgress/index.ts b/dashboard/src/oven/LoopProgress/index.ts new file mode 100644 index 00000000..37708fb7 --- /dev/null +++ b/dashboard/src/oven/LoopProgress/index.ts @@ -0,0 +1 @@ +export { LoopProgress } from "./LoopProgress"; diff --git a/dashboard/src/oven/runtime/OvenNode.tsx b/dashboard/src/oven/runtime/OvenNode.tsx index 4e497a11..cdc1fc80 100644 --- a/dashboard/src/oven/runtime/OvenNode.tsx +++ b/dashboard/src/oven/runtime/OvenNode.tsx @@ -13,6 +13,7 @@ import { formatRegistry } from "../OvenView/registries"; import { buildLogTableProps } from "./log-table-adapter"; import { ModelLabView, type ModelLabPayload } from "../ModelLabView"; import { LoopGraph, type LoopGraphProjection } from "../../components/LoopGraph"; +import { LoopProgress } from "../LoopProgress"; export type OvenNodeDef = { kind: string; attributes?: Record; bindings?: Record; children?: OvenNodeDef[] }; export type OvenNodeProps = { node: OvenNodeDef; ir: OvenIr; state: OvenState; dispatch: (action: OvenAction) => void; item?: unknown; path?: string }; @@ -72,8 +73,9 @@ export function OvenNode({ node, ir, state, dispatch, item, path = "root" }: Ove const run = runtimeSource(state.payload, item, attrs(node).source) as (LoopGraphProjection & { diagnostic?: "corrupt" | "stale" }) | null | undefined; return ; } + if (node.kind === "loop-progress") return ; if (node.kind === "log-table") return ; - if (["checklist-current", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards"].includes(node.kind)) return ; + if (["checklist-current", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards"].includes(node.kind)) return ; if (["mode-toggle", "domain-tabs", "field-toolbar", "pagination"].includes(node.kind)) return ; if (["field-list", "refresh-status", "verdict-header", "metric-tiles", "domain-note", "frame-card"].includes(node.kind)) return ; if (node.kind === "box") return {(node.children ?? []).map((child, index) => )}; diff --git a/dashboard/src/oven/runtime/widget-adapters.tsx b/dashboard/src/oven/runtime/widget-adapters.tsx index 226ff15e..06d38937 100644 --- a/dashboard/src/oven/runtime/widget-adapters.tsx +++ b/dashboard/src/oven/runtime/widget-adapters.tsx @@ -4,6 +4,7 @@ import { ChecklistBurnPanel } from "../ChecklistBurnPanel/ChecklistBurnPanel"; import { ChecklistEventCards } from "../ChecklistEventCards/ChecklistEventCards"; import { ChecklistLedger } from "../ChecklistLedger/ChecklistLedger"; import { ChecklistCurrent } from "../ChecklistCurrent/ChecklistCurrent"; +import { ChecklistWorkspace } from "../ChecklistWorkspace/ChecklistWorkspace"; import { DomainNote } from "../DomainNote"; import { FrameCard } from "../FrameCard"; import { MetricTiles } from "../MetricTiles"; @@ -59,6 +60,7 @@ export function WidgetAdapter({ node, ir, state, dispatch }: { node: Node; ir: O export function ChecklistWidgetAdapter({ node, payload }: { node: Node; payload: unknown }) { const data = resolvePointer(payload, String(attrs(node).source ?? "/")) as any; if (node.kind === "checklist-current") return ; + if (node.kind === "checklist-workspace") return ; if (node.kind === "checklist-burn-panel") return ; if (node.kind === "checklist-ledger") return ; if (node.kind === "checklist-event-cards") return ; diff --git a/loops/review/instructions.md b/loops/review/instructions.md index 23c75f8d..8079f335 100644 --- a/loops/review/instructions.md +++ b/loops/review/instructions.md @@ -1,5 +1,33 @@ +## start +Outline this decompose scope and identify candidate constraints. + +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn +after convergence. + +## decompose +Break the next candidate into independent chunks and define success criteria. + +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn +after convergence. + ## implement -Implement the assigned Burnlist item. Run the required repository checks and report one structured final result. +Make a first-pass implementation that satisfies the decomposed chunks. + +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn +after convergence. ## review -Independently review the current candidate without writing. Report approve, reject, or escalate with bounded findings. +Review the current implementation for correctness and regression risk. + +## integrate +Merge reviewed work with baseline and resolve any follow-up. + +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn +after convergence. + +## final-review +Do a final inspection for safety, quality, and handoff readiness. diff --git a/loops/review/review.loop b/loops/review/review.loop index fc7b5711..05ee5580 100644 --- a/loops/review/review.loop +++ b/loops/review/review.loop @@ -1,21 +1,34 @@ - - - - - - + + + + + + + + + + + - - - - - + + + + + + + + + + + + + diff --git a/ovens/catalog.json b/ovens/catalog.json index 86c88e9c..a0d65eea 100644 --- a/ovens/catalog.json +++ b/ovens/catalog.json @@ -24,6 +24,17 @@ "maturity": "shipped", "runtimeCompatibility": "burnlist-oven-runtime@1" }, + { + "id": "loop-progress", + "version": "0.1.0", + "inputContract": "checklist-progress@1", + "renderContract": "checklist-progress@1", + "dataInput": "json-payload", + "producer": "burnlist-checklist-progress", + "routeKind": "burnlist-lens", + "maturity": "shipped", + "runtimeCompatibility": "burnlist-oven-runtime@1" + }, { "id": "model-lab", "version": "0.1.0", diff --git a/ovens/checklist/checklist.oven b/ovens/checklist/checklist.oven index d378765a..ed513008 100644 --- a/ovens/checklist/checklist.oven +++ b/ovens/checklist/checklist.oven @@ -17,7 +17,7 @@ - + diff --git a/ovens/loop-progress/instructions.md b/ovens/loop-progress/instructions.md new file mode 100644 index 00000000..94b7de81 --- /dev/null +++ b/ovens/loop-progress/instructions.md @@ -0,0 +1,24 @@ +# Loop Progress + +`loop-progress.oven` is a compact, read-only lens over canonical Burnlist item +and Loop Run data. It answers what is happening, why it matters, which +subsystem and declared files are in scope, and where that work sits in the +whole Burnlist architecture. + +## Data Shape + +- Input mode: `json-payload`. +- Runtime validator: `validateGenericJsonData`. +- Starter data: none. +- Render contract: `checklist-progress@1`. + +## State Contract + +- Canonical sources: the selected item contract, active checklist order, + frozen Loop assignment, and item-scoped Run projection. +- Hook activity is displayed as unavailable until a bounded hook projection is + part of the canonical payload. It is never inferred from declared files. + +The Oven is declarative and read-only. Selecting an item changes its declared +context and assigned Loop preview; it does not change the authoritative Run or +its active node. diff --git a/ovens/loop-progress/loop-progress.oven b/ovens/loop-progress/loop-progress.oven new file mode 100644 index 00000000..0ff6c309 --- /dev/null +++ b/ovens/loop-progress/loop-progress.oven @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 4b9ade7e..8ee71d8e 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,8 +2,8 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-CNSeyTpO.js", - "dashboard/dist/assets/index-CWvgOwCY.css", + "dashboard/dist/assets/index-BVVV40J5.js", + "dashboard/dist/assets/index-BmWMxaDn.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", "loops/review/instructions.md", @@ -24,6 +24,8 @@ "ovens/differential-testing/example/candidate.json", "ovens/differential-testing/example/reference.json", "ovens/differential-testing/instructions.md", + "ovens/loop-progress/instructions.md", + "ovens/loop-progress/loop-progress.oven", "ovens/model-lab/engine/model-lab-contract.mjs", "ovens/model-lab/engine/model-lab-handler.mjs", "ovens/model-lab/instructions.md", @@ -69,8 +71,15 @@ "skills/burnlist/references/differential-testing-adapter-sdk.md", "skills/burnlist/references/differential-testing-data.md", "skills/burnlist/references/getting-started.md", + "skills/burnlist/references/host-execution.md", "skills/burnlist/references/installation.md", "skills/burnlist/references/loop-capability-example.json", + "skills/burnlist/references/loop-providers/agy.md", + "skills/burnlist/references/loop-providers/claude-native.md", + "skills/burnlist/references/loop-providers/codex-cli.md", + "skills/burnlist/references/loop-providers/codex-native.md", + "skills/burnlist/references/loop-providers/custom.md", + "skills/burnlist/references/loop-providers/grok.md", "skills/burnlist/references/oven-authoring.md", "skills/burnlist/references/oven-contract.md", "skills/burnlist/references/oven-event-coordination.md", @@ -128,6 +137,7 @@ "src/loops/contracts/check-result.mjs", "src/loops/contracts/contract.mjs", "src/loops/contracts/finding.mjs", + "src/loops/contracts/host-execution.mjs", "src/loops/dsl/canonical.mjs", "src/loops/dsl/compile.mjs", "src/loops/dsl/diagnostics.mjs", @@ -138,12 +148,15 @@ "src/loops/dsl/ir-validate.mjs", "src/loops/dsl/loop-xml.mjs", "src/loops/dsl/package-read.mjs", + "src/loops/events/activity-projection.mjs", "src/loops/events/projection-events.mjs", "src/loops/run/binder.mjs", "src/loops/run/budgets.mjs", "src/loops/run/candidate.mjs", "src/loops/run/controller.mjs", "src/loops/run/current-authority.mjs", + "src/loops/run/host-execution.mjs", + "src/loops/run/production-runner.mjs", "src/loops/run/read-projection.mjs", "src/loops/run/run-artifacts.mjs", "src/loops/run/run-claim.mjs", @@ -156,6 +169,7 @@ "src/loops/run/run-store.mjs", "src/loops/run/runner.mjs", "src/loops/run/state-machine.mjs", + "src/loops/view/ascii.mjs", "src/loops/view/render.mjs", "src/ovens/built-in-handlers.mjs", "src/ovens/dsl/oven-compile.mjs", @@ -166,6 +180,7 @@ "src/ovens/dsl/xml-scan.mjs", "src/ovens/handlers/checklist.mjs", "src/ovens/handlers/generic-json-handler.mjs", + "src/ovens/handlers/loop-progress.mjs", "src/ovens/official-oven-catalog.mjs", "src/ovens/oven-contract.mjs", "src/ovens/oven-data-validate.mjs", diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 5710f3bf..7b5e326f 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -89,24 +89,28 @@ export const verificationTestFiles = [ "src/loops/assignment/assignment.test.mjs", "src/loops/dsl/compile.test.mjs", "src/loops/dsl/package-read.test.mjs", + "src/loops/dsl/project-strategies.test.mjs", "src/loops/view/render.test.mjs", "src/loops/capabilities/capabilities.test.mjs", "src/loops/adapters/codex-cli.test.mjs", "src/loops/adapters/normalized-invocation.test.mjs", "src/loops/agents/profile.test.mjs", "src/loops/contracts/contracts.test.mjs", + "src/loops/contracts/host-execution.test.mjs", "src/loops/run/run-store.test.mjs", "src/loops/run/controller.test.mjs", "src/loops/run/current-authority.test.mjs", "src/loops/run/launch-authority.test.mjs", "src/loops/run/run-clock.test.mjs", "src/loops/events/projection-events.test.mjs", + "src/loops/events/activity-projection.test.mjs", "src/loops/run/run-journal.test.mjs", "src/loops/run/run-fold.test.mjs", "src/loops/run/state-machine.test.mjs", "src/loops/run/budgets.test.mjs", "src/loops/run/runner.test.mjs", "src/loops/run/binder.test.mjs", + "src/loops/run/run-claim.test.mjs", "src/loops/run/reviewer-isolation.test.mjs", "scripts/verify-source-scan.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 3d2416e2..6fdaad9e 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -413,6 +413,7 @@ assertSkillSet(repoRoot, ["burnlist"]); const officialOvenExpectations = new Map([ ["checklist", { name: "Checklist", validator: "validateGenericJsonData" }], ["differential-testing", { name: "Differential Testing", validator: "validateDifferentialTestingRuntimeData" }], + ["loop-progress", { name: "Loop Progress", validator: "validateGenericJsonData" }], ["model-lab", { name: "Model Lab", validator: "validateModelLabRuntimeData" }], ["performance-tracing", { name: "Performance Tracing", validator: "validatePerformanceTracingRuntimeData" }], ["streaming-diff", { name: "Streaming Diff" }], diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index 5bebcd43..b6660805 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -32,6 +32,8 @@ Read references only when their trigger applies: - `references/oven-authoring.md`: authoring or inspecting Ovens from the `burnlist oven` CLI, the widget/format vocabulary, and source-binding conventions. - `references/creating-ovens.md`: authoring a new .oven declarative source (grammar, elements, binding, themes, compile-to-IR walkthrough). - `references/oven-event-coordination.md`: mandatory for multi-Burnlist worker coordination, generic Oven progress events, replayable subscriptions, and event-triggered coordinator wakeups. +- `references/host-execution.md`: generic host claim/execute/report protocol for a prepared Loop Run; read before a host executes a claimed agent node. +- `references/loop-providers/.md`: optional bounded provider recipe for Claude native, Codex native, Codex CLI, AGY, Grok, or a custom host. Read only after choosing that provider; native hosts do not need a recipe to use the generic protocol. Do not load cold references for a normal single-item implementation unless needed. If a task touches a cold-rule area, read the matching reference before editing Burnlist state in that area. @@ -118,7 +120,7 @@ For detailed examples and banned narration, read `references/burnlist-visible-ou ## Dashboard Boundary -The live dashboard is mandatory as an observer, but agents do not own its server lifecycle. Do not start a per-plan server, manage ports, claim a dashboard URL, or inspect dashboard UI unless the user asks or dashboard behavior is the task. +The live dashboard is mandatory as an observer, but agents do not own its server lifecycle. Do not start a per-plan server, manage ports, claim a dashboard URL, or inspect dashboard UI unless the user asks or dashboard behavior is the task. Adopting a shipped, pinned, read-only observer Oven is safe and needs no separate permission; it does not authorize any dashboard server lifecycle or UI inspection. The dashboard scans lifecycle folders and is read-only. `burnlist.md` and lifecycle folder location are canonical task state. Dashboard charts/logs/repo graphs are observer evidence, not implementation proof. @@ -180,6 +182,21 @@ Accepted profile models are `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.3-codex-spark`; efforts are `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. +`builtin:codex-cli` is currently the only Burnlist-managed process adapter. +That does not limit native host orchestration: Codex normally runs Codex +subagents natively, Claude normally runs Claude subagents natively, and any host +may claim and report Loop nodes through native orchestration. Claude, Grok, AGY, +and custom hosts are not managed adapters, though they may optionally invoke +Codex through a `codex-cli` recipe; do not promise or configure them as managed +Loop backends. + +For a host-orchestrated agent node, read `references/host-execution.md` before +claiming it. The core protocol is provider-neutral: claim the prepared node, +execute its exact bounded envelope through an available native or external +mechanism, then return one bound report. Never invent a transition or execute a +managed check. Provider recipes are optional detail, not a prerequisite for +native host orchestration. + ```sh burnlist agent profile add maker --adapter builtin:codex-cli --binary --model --effort --authority write burnlist agent profile add reviewer --adapter builtin:codex-cli --binary --model --effort --authority read diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md new file mode 100644 index 00000000..009bde23 --- /dev/null +++ b/skills/burnlist/references/host-execution.md @@ -0,0 +1,102 @@ +# Host-executed Loop nodes + +Use this reference when a host executes a prepared agent node. It is the whole +provider-neutral contract; choose a `loop-providers/` recipe only if its +invocation details help. + +## Claim, execute, report + +1. Create and inspect the Run, then claim its current agent node: + + ```sh + burnlist loop create item:# + burnlist loop claim run: + ``` + + `claim` returns canonical JSON containing a `claim` and an `execution` + envelope. Treat both as opaque, bounded, single-use authority. Do not alter + or reconstruct their fields. + +2. Decode, inspect, and execute the exact prepared invocation. `execution` is + a canonical `burnlist-loop-host-execution@1` object: base64-decode its + `invocationInput` to the canonical `burnlist-loop-invocation-input@1` JSON. + That object contains base64 `instructionBytes`, `itemText`, and + `candidateContext`; decode them as UTF-8 only for the executor. It also + declares the node `mode`, `role`, `authority`, legal outcomes, and required + evidence ids. Keep `execution`, its + `dispatchAuthority`, and every identity field byte-for-byte unchanged. Do + not edit, reserialize, or replace those authority-bearing inputs. + + The host may choose its provider, subagent arrangement, and additional + non-authoritative context, but must preserve the complete correlation tuple: + + ```text + runId + nodeId + attempt + claimId + invocationId + assignmentId + + recipeRevision + policyRevision + inputCandidate + ``` + + Execute the supplied instructions against the exact assigned item and + candidate context. Respect read/write authority and legal outcomes. Do not choose + a graph edge, declare a destination, or run a deterministic check; Burnlist + owns all three. + +3. Produce one canonical `burnlist-loop-host-report@1` whose `agent-result@1` + is bound to that same tuple, with only a legal node-mode outcome. Copy the + identity values exactly from `execution`; do not use these placeholders as + invented values. A task accepts `complete`; review accepts `approve`, + `reject`, or `escalate`. A task has empty `findings` and + `resolvedFindingIds`. + + Task report with unavailable telemetry: + + ```json + {"schema":"burnlist-loop-host-report@1","result":{"schema":"agent-result@1","runId":"","nodeId":"","attempt":,"claimId":"","assignmentId":"","invocationId":"","recipeRevision":"","policyRevision":"","inputCandidate":"","outcome":"complete","findings":[],"resolvedFindingIds":[]},"telemetry":null} + ``` + + Reviewer report with the full optional telemetry shape (replace only values + the host observed; the sample timing and token fields are intentionally + unavailable): + + ```json + {"schema":"burnlist-loop-host-report@1","result":{"schema":"agent-result@1","runId":"","nodeId":"","attempt":,"claimId":"","assignmentId":"","invocationId":"","recipeRevision":"","policyRevision":"","inputCandidate":"","outcome":"approve","findings":[],"resolvedFindingIds":[]},"telemetry":{"schema":"burnlist-loop-host-telemetry@1","provenance":"host-reported","executor":"provider-executor","displayName":null,"provider":null,"model":null,"effort":null,"startedAt":null,"completedAt":null,"inputTokens":null,"outputTokens":null}} + ``` + + For a `reject` or `escalate`, carry forward every still-open finding, add + any new content-addressed finding, and resolve only currently open ids. + Optional telemetry always uses `burnlist-loop-host-telemetry@1` with + `provenance: "host-reported"`; unknown values remain `null`, never guessed. + +4. Write the report to a regular, non-symlink file no larger than 256 KiB and + submit it by the claim id: + + ```sh + burnlist loop report cl1-sha256: --result ./host-report.json + ``` + + An identical retransmission is safe. A conflicting replay, expired or stale + claim, workspace/candidate drift, illegal outcome, or identity mismatch fails + closed. Inspect the Run again rather than editing a rejected report. + +5. If the host cannot finish, do not report a made-up result. Resolve its live + claim once: + + ```sh + burnlist loop abandon cl1-sha256: --reason host-cancelled + ``` + + The only reasons are `host-cancelled`, `host-lost`, and (after expiry) + `expired`. Recovery can terminalize as `needs-human`; it does not make the + host the transition authority. + +## Ownership and observability + +The host owns invocation and optional best-effort telemetry. Burnlist owns the +frozen graph, claim authority, validation, deterministic checks, transition +selection, canonical journal, and item completion. Native provider execution, +the managed `builtin:codex-cli` process adapter, and external tools all feed +this same boundary. `builtin:codex-cli` is optional for cross-engine use, not +the only Loop mechanism. + +Installable skills and Streaming Diff hooks are independent. Neither installs, +selects, or starts a host executor; hooks only provide optional observational +activity. diff --git a/skills/burnlist/references/loop-providers/agy.md b/skills/burnlist/references/loop-providers/agy.md new file mode 100644 index 00000000..c4ae8077 --- /dev/null +++ b/skills/burnlist/references/loop-providers/agy.md @@ -0,0 +1,17 @@ +# AGY recipe + +Use only when AGY is available to the host. Read +[Host-executed Loop nodes](../host-execution.md) first, and defer to an +installed AGY skill for its current invocation syntax. + +- **Invocation ownership:** the host invokes and supervises AGY; AGY is not a + Burnlist-managed adapter. +- **Context freedom:** choose AGY's prompt and session mechanics while carrying + the exact prepared envelope as the authority-bearing input. +- **Identity:** preserve the complete generic correlation tuple through AGY and + bind its final result to that tuple. +- **Telemetry:** use AGY data only when actually returned and label it + `host-reported`; otherwise leave it `null`. +- **Fallback:** use native host execution, Codex CLI where deliberately + configured, or abandon the claim. Never invent provider output or a graph + transition. diff --git a/skills/burnlist/references/loop-providers/claude-native.md b/skills/burnlist/references/loop-providers/claude-native.md new file mode 100644 index 00000000..16889768 --- /dev/null +++ b/skills/burnlist/references/loop-providers/claude-native.md @@ -0,0 +1,17 @@ +# Claude native recipe + +Use only when Claude is the host and its native subagent facility is available. +Read [Host-executed Loop nodes](../host-execution.md) first. + +- **Invocation ownership:** Claude hosts and supervises its own Claude + subagent; Burnlist does not launch it. +- **Context freedom:** pass the exact claim envelope unchanged plus concise, + non-authoritative working context. Do not substitute a new item, candidate, + or destination. +- **Identity:** preserve every field in the generic correlation tuple in the + subagent handoff and copy them unchanged into the final bound report. +- **Telemetry:** report provider values only when Claude exposes them; label + them `host-reported`, otherwise use `null`. +- **Fallback:** if native delegation is unavailable, execute directly as the + Claude host or use another available mechanism, then follow the same report + and abandonment rules. Do not configure Claude as a managed adapter. diff --git a/skills/burnlist/references/loop-providers/codex-cli.md b/skills/burnlist/references/loop-providers/codex-cli.md new file mode 100644 index 00000000..1fd74061 --- /dev/null +++ b/skills/burnlist/references/loop-providers/codex-cli.md @@ -0,0 +1,32 @@ +# Codex CLI recipe + +Read [Host-executed Loop nodes](../host-execution.md) first. There are two +different Codex CLI paths; never describe a direct host launch as the managed +adapter. + +## Managed `builtin:codex-cli` + +- **Invocation ownership:** the Burnlist runner launches and cancels the + configured process through `loop run` or `loop resume`; the host does not + claim/report that managed invocation. +- **Context and identity:** the runner supplies the frozen input and preserves + its identity internally; neither the child nor a supervising host selects an + edge. +- **Telemetry:** runner measurements are `managed`; missing values are + unavailable, not inferred. +- **Fallback:** if managed setup is unavailable, use native host orchestration + or the direct host-claim path below; do not call that fallback an adapter. + +## Direct Codex CLI for a host claim + +- **Invocation ownership:** the host launches, supervises, and cancels its own + Codex CLI process after `burnlist loop claim`; this is not `builtin:codex-cli`. +- **Context freedom:** give Codex the exact decoded invocation input and only + bounded supplemental context. Do not ask it to choose a transition. +- **Identity:** retain the complete generic correlation tuple and submit the + one bound host report under the original claim id. +- **Telemetry:** observations from this host-owned process are + `host-reported`; unavailable fields are `null`. +- **Fallback:** use native Codex orchestration, another available host + mechanism, or abandon the claim. `builtin:codex-cli` remains the only + Burnlist-managed process adapter, not the only way a Loop node can run. diff --git a/skills/burnlist/references/loop-providers/codex-native.md b/skills/burnlist/references/loop-providers/codex-native.md new file mode 100644 index 00000000..6fa17dd4 --- /dev/null +++ b/skills/burnlist/references/loop-providers/codex-native.md @@ -0,0 +1,16 @@ +# Codex native recipe + +Use only when Codex is the host and its native subagent facility is available. +Read [Host-executed Loop nodes](../host-execution.md) first. + +- **Invocation ownership:** Codex normally orchestrates Codex subagents + natively; Burnlist does not launch them. +- **Context freedom:** forward the exact envelope and any bounded local context + needed to execute it, without changing authority fields or graph intent. +- **Identity:** carry the complete generic correlation tuple through the + handoff and final report exactly once. +- **Telemetry:** report observed Codex/model/effort/usage only as + `host-reported`; retain unavailable values as `null`. +- **Fallback:** execute as the host if native delegation is unavailable. A + `codex-cli` process recipe is optional for a different host, not required for + Codex-native execution. diff --git a/skills/burnlist/references/loop-providers/custom.md b/skills/burnlist/references/loop-providers/custom.md new file mode 100644 index 00000000..d55d9678 --- /dev/null +++ b/skills/burnlist/references/loop-providers/custom.md @@ -0,0 +1,15 @@ +# Custom host recipe + +Use for a host not covered by another recipe. Read +[Host-executed Loop nodes](../host-execution.md) first. + +- **Invocation ownership:** the custom host owns all execution, process, and + cancellation behavior; Burnlist owns no custom runtime plugin. +- **Context freedom:** it may add local context, but must execute the supplied + envelope unchanged and may not add graph authority. +- **Identity:** propagate the complete generic correlation tuple unchanged into + the sole final report. +- **Telemetry:** use only observed values with `host-reported`; leave absent + fields `null`. +- **Fallback:** execute directly if possible; otherwise abandon the live claim + with a permitted reason. A custom host is never silently a managed adapter. diff --git a/skills/burnlist/references/loop-providers/grok.md b/skills/burnlist/references/loop-providers/grok.md new file mode 100644 index 00000000..0c6f27c5 --- /dev/null +++ b/skills/burnlist/references/loop-providers/grok.md @@ -0,0 +1,16 @@ +# Grok recipe + +Use only when Grok is available to the host. Read +[Host-executed Loop nodes](../host-execution.md) first, and defer to an +installed Grok skill for its current invocation syntax. + +- **Invocation ownership:** the host invokes and supervises Grok; Grok is not a + Burnlist-managed adapter. +- **Context freedom:** select Grok session mechanics while retaining the exact + prepared envelope as the only transition-relevant input. +- **Identity:** preserve the full generic correlation tuple and bind the final + report to it without reconstruction. +- **Telemetry:** emit Grok/model/usage only when known, as `host-reported`; + unknown values are `null`. +- **Fallback:** use a native host path, a deliberately configured Codex CLI + recipe, or abandon the claim. Do not promise Grok as a managed Loop backend. diff --git a/skills/burnlist/references/oven-authoring.md b/skills/burnlist/references/oven-authoring.md index d2c8326b..180569f8 100644 --- a/skills/burnlist/references/oven-authoring.md +++ b/skills/burnlist/references/oven-authoring.md @@ -92,7 +92,9 @@ burnlist oven fork `ovenRevision` plus the same origin and catalog metadata. - `use` adopts a shipped Oven and, only if the shipped directory contains an exact `example/data.json`, validates and installs that example. Without one, - it adopts only and prints the exact `oven set` next step. + it adopts only. A catalogued Burnlist lens is supplied by core transport, so + it prints that no binding or `oven set` is needed; a data-bound Oven prints + the exact `oven set` next step. - `set` reads JSON from a file, stdin (`-`), or an inline JSON argument, validates before mutation, and atomically publishes the canonical data file plus binding. @@ -120,7 +122,8 @@ selects the repository whose binding storage is used. ## Validated `use` and `set` -Use a shipped Oven in a repository, then set its data when no starter exists: +Use a data-bound shipped Oven in a repository, then set its data when no +starter exists: ```sh burnlist oven use differential-testing --repo . @@ -130,12 +133,15 @@ burnlist oven set differential-testing ./differential-testing.json --repo . `use` keeps the existing `adopt` and pin semantics. It looks only for the non-executable shipped file `ovens//example/data.json`. When that exact file exists, `use` validates it and transactionally installs the Oven, data, and -binding. When it does not exist, adoption still succeeds, no data or binding is -created, and the CLI prints `burnlist oven set --repo `. -Reference/candidate inputs, test fixtures, schemas, and instructions are never -converted into starter data. The optional example is not vendored and does not -enter the Oven revision or pin. `--force` has the same deterministic duplicate -behavior as `adopt`. +binding. When it does not exist, adoption still succeeds and no data or binding +is created. The official catalog's `routeKind` decides the next step: a +`burnlist-lens` (for example Checklist or Loop Progress) is fed by Burnlist +core transport and needs neither a binding nor `oven set`; a `repo-oven` prints +`burnlist oven set --repo `. Never fabricate a JSON payload +for a core-fed Oven. Reference/candidate inputs, test fixtures, schemas, and +instructions are never converted into starter data. The optional example is not +vendored and does not enter the Oven revision or pin. `--force` has the same +deterministic duplicate behavior as `adopt`. Examples and fixtures test only the mechanics they target. The official catalog does not qualify acceptance or retained evidence. Its generated agent guidance diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index 2285c6d7..6bd06c9a 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -47,6 +47,9 @@ test("top-level and Oven help expose the validated use and set flow", () => { assert.match(top.stdout, /burnlist oven <[^\n]*use[^\n]*set[^\n]*>/u); assert.match(top.stdout, /burnlist loop view \[--repo \]/u); assert.match(top.stdout, /burnlist loop create \[--repo \]/u); + assert.match(top.stdout, /burnlist loop next\|claim \[--repo \]/u); + assert.match(top.stdout, /burnlist loop report --result \[--repo \]/u); + assert.match(top.stdout, /burnlist loop abandon --reason \[--repo \]/u); assert.match(top.stdout, /burnlist loop list \[--repo \]/u); assert.match(top.stdout, /burnlist loop run\|resume \[--repo \]/u); assert.match(top.stdout, /burnlist loop status\|inspect \[--repo \]/u); @@ -92,7 +95,7 @@ test("nested Loop help snapshots every Stage 1 control", () => { try { const result = run(context.directory, ["loop", "--help"]); assert.equal(result.status, 0, result.stderr); - for (const command of ["create", "list", "run|pause|resume|stop|complete", "status|inspect", "reconcile"]) { + for (const command of ["create", "next|claim", "report --result ", "abandon --reason ", "list", "run|pause|resume|stop|complete", "status|inspect", "reconcile"]) { assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u")); } assert.match(result.stdout, /pause\|resume\|stop\|complete /u); @@ -201,7 +204,7 @@ test("Review Loop documentation command matrix runs against the production fixtu const executed = spawnSync(process.execPath, [cli, "loop", "run", runId, "--repo", repo], { cwd: repo, encoding: "utf8", - env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" }, + env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" }, }); assert.equal(executed.status, 0, executed.stderr); assert.equal(JSON.parse(executed.stdout).state, "converged"); @@ -219,7 +222,7 @@ test("Review Loop documentation command matrix runs against the production fixtu writeFileSync(counter, "0"); const resumed = spawnSync(process.execPath, [cli, "loop", "resume", pausedRun, "--repo", repo], { cwd: repo, encoding: "utf8", - env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" }, + env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" }, }); assert.equal(resumed.status, 0, resumed.stderr); assert.equal(JSON.parse(resumed.stdout).state, "converged"); @@ -235,8 +238,8 @@ test("Review Loop documentation command matrix runs against the production fixtu assert.equal(loop("assign", reconciledRef, "loop:builtin:review").status, 0); const reconciledRun = JSON.parse(loop("create", reconciledRef).stdout).runId; const store = runStore(repo), acquired = store.acquireLease(reconciledRun); - store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 }); - store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); + store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "start", attempt: 1 }); + store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "start", attempt: 1, invocationId: "a".repeat(32) }); const reconciled = loop("reconcile", reconciledRun, "--recovery-proof", acquired.recoveryProof); assert.equal(reconciled.status, 0, reconciled.stderr); assert.equal(JSON.parse(reconciled.stdout).state, "needs-human"); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs index 2df8dfab..68415d9c 100644 --- a/src/cli/loop-cli.mjs +++ b/src/cli/loop-cli.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { resolve } from "node:path"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; import { assignLoopItem, prepareItemMutation, unassignLoopItem } from "../loops/assignment/assignment.mjs"; import { resolveLoopAuthority } from "../loops/assignment/resolver.mjs"; import { loopConfigUsage, runLoopConfigCli } from "./loop-config-cli.mjs"; @@ -10,22 +11,53 @@ import { createProductionRun, createStoredProductionRunRunner } from "../loops/r import { completeLoopRun } from "../loops/completion/completion.mjs"; function usageText() { return loopConfigUsage(); } +function usageError(message = usageText()) { return Object.assign(new Error(message), { exitCode: 2 }); } function options(tokens) { - const positionals = []; let repo = null, recoveryProof = null; + const positionals = []; let repo = null, recoveryProof = null, resultFile = null, reason = null; for (let index = 0; index < tokens.length; index += 1) { if (tokens[index] === "--repo") { - if (repo !== null) throw new Error("--repo must be specified at most once."); + if (repo !== null) throw usageError("--repo must be specified at most once."); repo = tokens[++index]; - if (!repo || repo.startsWith("--")) throw new Error("--repo requires a path."); + if (!repo || repo.startsWith("--")) throw usageError("--repo requires a path."); } else if (tokens[index] === "--recovery-proof") { - if (recoveryProof !== null) throw new Error("--recovery-proof must be specified at most once."); - recoveryProof = tokens[++index]; if (!/^[a-f0-9]{64}$/u.test(recoveryProof ?? "")) throw new Error("--recovery-proof requires a 64-character lowercase hex value."); + if (recoveryProof !== null) throw usageError("--recovery-proof must be specified at most once."); + recoveryProof = tokens[++index]; if (!/^[a-f0-9]{64}$/u.test(recoveryProof ?? "")) throw usageError("--recovery-proof requires a 64-character lowercase hex value."); } - else if (tokens[index].startsWith("--")) throw new Error(`Unknown option: ${tokens[index]}`); + else if (tokens[index] === "--result") { + if (resultFile !== null) throw usageError("--result must be specified at most once."); + resultFile = tokens[++index]; if (!resultFile || resultFile.startsWith("--")) throw usageError("--result requires a file."); + } + else if (tokens[index] === "--reason") { + if (reason !== null) throw usageError("--reason must be specified at most once."); + reason = tokens[++index]; if (!reason || reason.startsWith("--")) throw usageError("--reason requires host-cancelled, host-lost, or expired."); + } + else if (tokens[index].startsWith("--")) throw usageError(`Unknown option: ${tokens[index]}`); else positionals.push(tokens[index]); } - return { positionals, recoveryProof, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; + return { positionals, recoveryProof, resultFile, reason, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; +} +function validateVerbOptions(verb, opts) { + if (opts.recoveryProof && verb !== "reconcile") throw usageError(); + if (opts.resultFile && verb !== "report" || verb === "report" && !opts.resultFile) throw usageError(); + if (opts.reason && verb !== "abandon" || verb === "abandon" && !opts.reason) throw usageError(); +} +function resultBytes(path) { + let fd; + try { + const target = resolve(process.cwd(), path), entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || entry.size < 2 || entry.size > 262_144) throw new Error("--result file is unsafe or exceeds bounds."); + fd = openSync(target, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); + const opened = fstatSync(fd); if (opened.dev !== entry.dev || opened.ino !== entry.ino || opened.size !== entry.size) throw new Error("--result file changed while opening."); + const bytes = Buffer.allocUnsafe(opened.size); let offset = 0; + while (offset < bytes.length) { const count = readSync(fd, bytes, offset, bytes.length - offset); if (count <= 0) throw new Error("--result file changed while opening."); offset += count; } + const after = fstatSync(fd), leaf = lstatSync(target); + if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || leaf.dev !== entry.dev || leaf.ino !== entry.ino || leaf.size !== entry.size) throw new Error("--result file changed while opening."); + return bytes; + } finally { if (fd !== undefined) closeSync(fd); } +} +function publicClaim(value) { + return { schema: "burnlist-loop-host-claim-response@1", claim: value.claim, execution: JSON.parse(value.envelope.toString("utf8")) }; } export async function renderLoopView({ selector, repoRoot, runReader }) { @@ -40,30 +72,40 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces const value = await runLoopConfigCli(tokens); stdout.write(value.output); return value; } const [verb, ...rest] = tokens; const opts = options(rest); + validateVerbOptions(verb, opts); if (verb === "create") { - if (opts.positionals.length !== 1 || opts.recoveryProof) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + if (opts.positionals.length !== 1) throw usageError(); const store = runStore(opts.repo), result = await createProductionRun({ repoRoot: opts.repo, store, itemRef: opts.positionals[0] }); stdout.write(`${JSON.stringify({ schema: "burnlist-loop-status@1", ...result.projection })}\n`); return result; } if (verb === "complete") { - if (opts.positionals.length !== 1 || opts.recoveryProof) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + if (opts.positionals.length !== 1) throw usageError(); const result = completeLoopRun({ repoRoot: opts.repo, runId: opts.positionals[0] }); stdout.write(`${JSON.stringify({ schema: "burnlist-loop-completion@1", ...result })}\n`); return result; } - if (["list", "status", "inspect", "run", "pause", "resume", "stop", "reconcile"].includes(verb)) { + if (["list", "status", "inspect", "next", "claim", "report", "abandon", "run", "pause", "resume", "stop", "reconcile"].includes(verb)) { const allowed = verb === "list" ? 0 : 1; - if (opts.positionals.length !== allowed) { const error = new Error(usageText()); error.exitCode = 2; throw error; } + if (opts.positionals.length !== allowed) throw usageError(); const store = runStore(opts.repo); - if (opts.recoveryProof && verb !== "reconcile") { const error = new Error(usageText()); error.exitCode = 2; throw error; } const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredProductionRunRunner({ repoRoot: opts.repo, store, runId })); const runners = new Map(), runtimeRunnerFor = (runId) => { if (!runners.has(runId)) runners.set(runId, suppliedRunnerFor(runId)); return runners.get(runId); }; - const controller = createLoopController({ store, runnerFor: runtimeRunnerFor }); + const controller = createLoopController({ store, runnerFor: runtimeRunnerFor, repoRoot: opts.repo }); const result = verb === "list" ? controller.list() : verb === "status" ? controller.status(opts.positionals[0]) : verb === "inspect" ? controller.inspect(opts.positionals[0]) + : verb === "next" ? controller.inspect(opts.positionals[0]) + : verb === "claim" ? publicClaim(controller.claim(opts.positionals[0])) + : verb === "report" ? controller.report(opts.positionals[0], resultBytes(opts.resultFile)) + : verb === "abandon" ? (() => { + const runId = store.resolveClaimRef(opts.positionals[0]); + if (store.read(runId).execution.terminal) { const error = new Error("ClaimRef is stale"); error.exitCode = 1; throw error; } + const active = controller.readClaim(runId); + if (!active || active.claim.claimId !== opts.positionals[0]) { const error = new Error("ClaimRef is stale"); error.exitCode = 1; throw error; } + return controller.abandonClaim(runId, { ...active.claim, reason: opts.reason }); + })() : verb === "pause" ? controller.pause(opts.positionals[0]) : verb === "stop" ? controller.stop(opts.positionals[0]) : verb === "reconcile" ? controller.reconcile(opts.positionals[0], opts.recoveryProof ? { generation: store.read(opts.positionals[0]).execution.generation, recoveryProof: opts.recoveryProof } : null) diff --git a/src/cli/loop-cli.test.mjs b/src/cli/loop-cli.test.mjs index d81bf1c2..a7db469a 100644 --- a/src/cli/loop-cli.test.mjs +++ b/src/cli/loop-cli.test.mjs @@ -105,17 +105,17 @@ test("loop view rejects duplicate, missing, and unknown --repo options", () => { const context = fixture(); try { const duplicate = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo, "--repo", context.repo]); - assert.equal(duplicate.status, 1); + assert.equal(duplicate.status, 2); assert.equal(duplicate.stdout, ""); assert.match(duplicate.stderr, /--repo must be specified at most once/u); const missing = runCommand(context.repo, ["loop", "view", "review", "--repo"]); - assert.equal(missing.status, 1); + assert.equal(missing.status, 2); assert.equal(missing.stdout, ""); assert.match(missing.stderr, /--repo requires a path/u); const unknown = runCommand(context.repo, ["loop", "view", "review", "--repo", context.repo, "--mystery"]); - assert.equal(unknown.status, 1); + assert.equal(unknown.status, 2); assert.equal(unknown.stdout, ""); assert.match(unknown.stderr, /Unknown option: --mystery/u); } finally { context.cleanup(); } diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs index 26e163d1..547aa249 100644 --- a/src/cli/loop-config-cli.mjs +++ b/src/cli/loop-config-cli.mjs @@ -10,7 +10,7 @@ import { resolveUmbrella } from "./umbrella.mjs"; const profileUsage = "Usage: burnlist agent profile add --adapter builtin:codex-cli --binary --model --effort --authority read|write [--repo ]"; const routeUsage = "Usage: burnlist route set --profile [--repo ]"; const agentUsage = `${profileUsage}\n burnlist agent doctor [--repo ]`; -const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop run|pause|resume|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report --result [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop run|pause|resume|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } function parse(tokens, allowed = []) { diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs index 11b55d54..2bb75c9e 100644 --- a/src/cli/loop-runtime-cli.test.mjs +++ b/src/cli/loop-runtime-cli.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +9,8 @@ import test from "node:test"; import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; import { runLoopCli } from "./loop-cli.mjs"; import { runStore } from "../loops/run/run-store.mjs"; +import { createLoopController } from "../loops/run/controller.mjs"; +import { prepareHostClaim } from "../loops/run/host-execution.mjs"; import { createProductionRun } from "../loops/run/binder.mjs"; import { fixtureRunId } from "../loops/run/run-test-fixtures.mjs"; @@ -19,6 +21,14 @@ function command(repo, args, env = {}) { assert.equal(result.stderr, "", `${args.join(" ")}: ${result.stderr}`); assert.equal(result.status, 0, `${args.join(" ")}: ${result.stdout}`); return result.stdout; } function created(repo) { return JSON.parse(command(repo, ["create", fixtureItemRef])).runId; } +function hostReport(execution, outcome) { + return { schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, attempt: execution.attempt, + claimId: execution.claimId, assignmentId: execution.assignmentId, invocationId: execution.invocationId, + recipeRevision: execution.recipeRevision, policyRevision: execution.policyRevision, + inputCandidate: execution.inputCandidate, outcome, findings: [], resolvedFindingIds: [], + }, telemetry: null }; +} test("real CLI control reads are stable, list is absent-state read-only, and stored authority drives run/resume", (t) => { const directory = mkdtempSync(join(tmpdir(), "m6-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); @@ -35,7 +45,7 @@ test("real CLI control reads are stable, list is absent-state read-only, and sto assert.equal(command(repo, ["status", first]), status); assert.equal(command(repo, ["inspect", first]), inspect); const authority = join(runs, Buffer.from(first).toString("hex"), "dispatch-authority.json"), bytes = readFileSync(authority); const counter = join(directory, "counter"); writeFileSync(counter, "0"); - const completed = JSON.parse(command(repo, ["run", first], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" })); + const completed = JSON.parse(command(repo, ["run", first], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" })); assert.equal(completed.state, "converged"); assert.deepEqual(readFileSync(authority), bytes); const blocked = spawnSync(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, encoding: "utf8" }); assert.equal(blocked.status, 1); assert.match(blocked.stderr, /current Run is still executable/u); @@ -44,7 +54,7 @@ test("real CLI control reads are stable, list is absent-state read-only, and sto test("real CLI fences active control and proof-gates reconcile", (t) => { const directory = mkdtempSync(join(tmpdir(), "m6-cli-fence-")); t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import{runStore}from${JSON.stringify(new URL("../loops/run/run-store.mjs", import.meta.url).href)};const s=runStore(process.argv[1]),a=s.acquireLease(process.argv[2]);s.append(process.argv[2],a.lease,"node-started",{nodeId:"implement",attempt:1});s.append(process.argv[2],a.lease,"invocation-started",{nodeId:"implement",attempt:1,invocationId:"${"a".repeat(32)}"});process.stdout.write(a.recoveryProof);`, repo, runId], { cwd: repo, encoding: "utf8" }); + const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import{runStore}from${JSON.stringify(new URL("../loops/run/run-store.mjs", import.meta.url).href)};const s=runStore(process.argv[1]),a=s.acquireLease(process.argv[2]);s.append(process.argv[2],a.lease,"node-started",{nodeId:"start",attempt:1});s.append(process.argv[2],a.lease,"invocation-started",{nodeId:"start",attempt:1,invocationId:"${"a".repeat(32)}"});process.stdout.write(a.recoveryProof);`, repo, runId], { cwd: repo, encoding: "utf8" }); assert.equal(held.status, 0, held.stderr); const result = spawnSync(process.execPath, [cli, "loop", "pause", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); assert.equal(result.status, 1); assert.match(result.stderr, /active foreground owner/u); @@ -56,7 +66,7 @@ test("real CLI fences active control and proof-gates reconcile", (t) => { test("loop complete is the public, idempotent completion command", (t) => { const directory = mkdtempSync(join(tmpdir(), "m8-cli-complete-")); t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), counter = join(directory, "counter"); - writeFileSync(counter, "0"); assert.equal(JSON.parse(command(repo, ["run", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" })).state, "converged"); + writeFileSync(counter, "0"); assert.equal(JSON.parse(command(repo, ["run", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" })).state, "converged"); assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, false); assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true); }); @@ -119,3 +129,200 @@ test("a second CLI SIGINT reaches controlled stop before the foreground runner s assert.equal(processObject.listenerCount("SIGINT"), 0); assert.equal(JSON.parse(output.value).runId, runId); }); + +test("host CLI claims once, rejects claim theft, accepts one bound report, rejects conflict, and observes the next node after restart", (t) => { + const directory = mkdtempSync(join(tmpdir(), "s1-host-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + assert.equal(JSON.parse(command(repo, ["next", runId])).currentNode, "start"); + const first = JSON.parse(command(repo, ["claim", runId])); + const replayed = spawnSync(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(replayed.status, 1); + assert.equal(replayed.stdout, ""); + assert.match(replayed.stderr, /already claimed/u); + assert.equal(runStore(repo).read(runId).journal.filter((record) => record.value.type === "external-claim-bound").length, 1); + const binding = first.execution; + const report = { + schema: "burnlist-loop-host-report@1", + result: { + schema: "agent-result@1", runId: binding.runId, nodeId: binding.nodeId, attempt: binding.attempt, + claimId: binding.claimId, assignmentId: binding.assignmentId, invocationId: binding.invocationId, + recipeRevision: binding.recipeRevision, policyRevision: binding.policyRevision, + inputCandidate: binding.inputCandidate, outcome: "complete", findings: [], resolvedFindingIds: [], + }, + telemetry: null, + }; + const resultPath = join(directory, "report.json"); writeFileSync(resultPath, `${JSON.stringify(report)}\n`); + const runRefReport = spawnSync(process.execPath, [cli, "loop", "report", runId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(runRefReport.status, 1); assert.match(runRefReport.stderr, /invalid ClaimRef/u); + const missingClaim = `cl1-sha256:${"f".repeat(64)}`; + const missing = spawnSync(process.execPath, [cli, "loop", "report", missingClaim, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(missing.status, 1); assert.match(missing.stderr, /ClaimRef is missing/u); + assert.equal(JSON.parse(command(repo, ["report", binding.claimId, "--result", resultPath])).currentNode, "decompose"); + assert.equal(JSON.parse(command(repo, ["report", binding.claimId, "--result", resultPath])).currentNode, "decompose"); + writeFileSync(resultPath, `${JSON.stringify({ ...report, telemetry: { + schema: "burnlist-loop-host-telemetry@1", provenance: "host-reported", executor: "fixture", + displayName: null, provider: null, model: null, effort: null, startedAt: null, completedAt: null, + inputTokens: null, outputTokens: null, + } })}\n`); + const conflict = spawnSync(process.execPath, [cli, "loop", "report", binding.claimId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(conflict.status, 1); assert.match(conflict.stderr, /conflicts/u); + assert.equal(JSON.parse(command(repo, ["next", runId])).currentNode, "decompose"); +}); + +test("host CLI rejects misplaced result options and unsafe files, then abandons only its active claim", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h4-host-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution, reportPath = join(directory, "report.json"); + writeFileSync(reportPath, `${JSON.stringify(hostReport(claimed, "complete"))}\n`); + const invoke = (...args) => spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); + const misplaced = invoke("claim", runId, "--result", reportPath); + assert.equal(misplaced.status, 2); assert.match(misplaced.stderr, /Usage: burnlist loop/u); + const missing = invoke("report", claimed.claimId); + assert.equal(missing.status, 2); assert.match(missing.stderr, /Usage: burnlist loop/u); + const symlink = join(directory, "report-link.json"); symlinkSync(reportPath, symlink); + const unsafe = invoke("report", claimed.claimId, "--result", symlink); + assert.equal(unsafe.status, 1); assert.match(unsafe.stderr, /unsafe or exceeds bounds/u); + assert.equal(runStore(repo).read(runId).projection.currentNode, "start"); + const invalidReason = invoke("abandon", claimed.claimId, "--reason", "retry"); + assert.equal(invalidReason.status, 1); assert.match(invalidReason.stderr, /invalid host claim abandonment reason/u); + const abandoned = invoke("abandon", claimed.claimId, "--reason", "host-cancelled"); + assert.equal(abandoned.status, 0, abandoned.stderr); assert.equal(JSON.parse(abandoned.stdout).state, "needs-human"); + const stale = invoke("abandon", claimed.claimId, "--reason", "host-cancelled"); + assert.equal(stale.status, 1); assert.match(stale.stderr, /stale|missing/u); +}); + +test("Loop option syntax and ownership fail as usage errors before every verb dispatch", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h4-option-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const invoke = (...args) => spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); + for (const args of [ + ["create", fixtureItemRef, "--result", "report.json"], ["complete", "run:bad", "--reason", "host-lost"], + ["assign", fixtureItemRef, "loop:builtin:review", "--recovery-proof", "a".repeat(64)], ["unassign", fixtureItemRef, "--result", "report.json"], + ["view", fixtureItemRef, "--reason", "host-lost"], ["list", "--recovery-proof", "a".repeat(64)], + ]) { + const result = invoke(...args); assert.equal(result.status, 2, args.join(" ")); assert.match(result.stderr, /Usage: burnlist loop/u); + } + for (const args of [ + ["list", "--unknown"], ["list", "--repo", repo], ["list", "--repo", repo], ["list", "--result"], + ["list", "--recovery-proof", "not-hex"], ["list", "--reason", "host-lost", "--reason", "host-cancelled"], + ]) { + const result = invoke(...args); assert.equal(result.status, 2, args.join(" ")); assert.doesNotMatch(result.stderr, /Loop control:/u); + } +}); + +test("a review claim refuses workspace drift before its report can advance", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h3-review-drift-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), resultPath = join(directory, "report.json"); + for (const expected of ["start", "decompose", "implement"]) { + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + assert.equal(execution.nodeId, expected); + writeFileSync(resultPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`); + command(repo, ["report", execution.claimId, "--result", resultPath]); + } + const store = runStore(repo), validation = store.acquireLease(runId).lease, candidateId = store.read(runId).execution.candidate.id; + store.append(runId, validation, "node-started", { nodeId: "validate", attempt: 1 }); + store.append(runId, validation, "invocation-started", { nodeId: "validate", attempt: 1, invocationId: "a".repeat(32) }); + store.append(runId, validation, "invocation-result", { invocationId: "a".repeat(32), kind: "pass", summary: "trusted", outputBytes: 0, candidateId }); + store.append(runId, validation, "edge-taken", { from: "validate", on: "pass", to: "review" }); store.releaseLease(runId, validation); + const controller = createLoopController({ store, repoRoot: repo }); + const capturedBeforeDrift = prepareHostClaim({ repoRoot: repo, replay: store.read(runId), authority: store.readAuthority(runId) }); + const preClaimDrift = join(repo, "src", "drift-before-review-claim.txt"); writeFileSync(preClaimDrift, "changed\n"); + assert.throws(() => controller.claim(runId, capturedBeforeDrift), /candidate drift before review claim/u); + rmSync(preClaimDrift); + const review = JSON.parse(command(repo, ["claim", runId])).execution; + assert.equal(review.nodeId, "review"); + writeFileSync(join(repo, "src", "drift-after-review-claim.txt"), "changed\n"); + writeFileSync(resultPath, `${JSON.stringify(hostReport(review, "approve"))}\n`); + const rejected = spawnSync(process.execPath, [cli, "loop", "report", review.claimId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(rejected.status, 1); assert.match(rejected.stderr, /candidate drifted/u); + assert.equal(store.read(runId).projection.currentNode, "review"); +}); + +test("host report retries complete every committed transaction tail after deliberate restart cuts", (t) => { + for (const cut of ["afterExternalReportAccepted", "afterExternalEdgeTaken"]) { + const directory = mkdtempSync(join(tmpdir(), `h2-host-${cut}-`)); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution; + const report = Buffer.from(`${JSON.stringify({ + schema: "burnlist-loop-host-report@1", + result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, + claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, + recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, + outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null, + })}\n`); + const cutStore = runStore(repo, { hooks: { [cut]() { throw new Error(`deliberate ${cut}`); } } }); + const interrupted = createLoopController({ store: cutStore, repoRoot: repo }); + assert.throws(() => interrupted.report(claimed.claimId, report), new RegExp(`deliberate ${cut}`, "u")); + const restartedStore = runStore(repo), restarted = createLoopController({ store: restartedStore, repoRoot: repo }); + assert.equal(restarted.report(claimed.claimId, report).currentNode, "decompose"); + const replay = restartedStore.read(runId); + assert.equal(replay.execution.lease, null); + assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 1); + assert.equal(replay.journal.filter((record) => record.value.type === "edge-taken").length, 1); + assert.equal(replay.journal.filter((record) => record.value.type === "lease-released").length, 1); + assert.equal(restarted.report(claimed.claimId, report).currentNode, "decompose"); + } +}); + +test("concurrent external claims expose the envelope to exactly one winner", async (t) => { + const directory = mkdtempSync(join(tmpdir(), "h2-host-claim-race-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claim = () => new Promise((done) => { + const child = spawn(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("close", (status) => done({ status, stdout, stderr })); + }); + const results = await Promise.all([claim(), claim()]), successful = results.filter((value) => value.status === 0); + assert.equal(successful.length, 1); + assert.match(results.find((value) => value.status !== 0).stderr, /locked|lease|active foreground owner|already claimed/u); + const retry = spawnSync(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(retry.status, 1); + assert.match(retry.stderr, /already claimed/u); + assert.equal(runStore(repo).read(runId).journal.filter((record) => record.value.type === "external-claim-bound").length, 1); +}); + +test("near journal capacity terminalizes before an external report can strand its lease", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h2-host-capacity-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution, invalidations = []; + const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", + result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, + claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, + recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, + outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null })}\n`); + // A reduced bound is a deterministic stand-in for records 252–255; the + // production ceiling remains MAX_JOURNAL_RECORDS. + const constrained = runStore(repo, { journalMaximum: 8, publishProjection(_root, replay) { invalidations.push(replay); } }); + const controller = createLoopController({ store: constrained, repoRoot: repo }); + assert.equal(controller.report(claimed.claimId, report).state, "budget-exhausted"); + const replay = constrained.read(runId); + assert.equal(replay.execution.lease, null); + assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 0); + assert.equal(replay.journal.at(-1).value.type, "terminal-node-committed"); + assert.equal(invalidations.length, 1); + assert.equal(invalidations[0].projection.itemRef, fixtureItemRef); + assert.throws(() => controller.report(claimed.claimId, report), /stale lease/u); +}); + +test("near journal capacity cut after edge retries only the release tail", (t) => { + const directory = mkdtempSync(join(tmpdir(), "h2-host-capacity-cut-")); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution, invalidations = []; + const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", + result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, + claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, + recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, + outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null })}\n`); + const cutStore = runStore(repo, { journalMaximum: 9, hooks: { afterExternalEdgeTaken() { throw new Error("near-capacity cut"); } } }); + assert.throws(() => createLoopController({ store: cutStore, repoRoot: repo }).report(claimed.claimId, report), /near-capacity cut/u); + const restartedStore = runStore(repo, { journalMaximum: 9, publishProjection(_root, replay) { invalidations.push(replay); } }); + assert.equal(createLoopController({ store: restartedStore, repoRoot: repo }).report(claimed.claimId, report).currentNode, "decompose"); + const replay = restartedStore.read(runId); + assert.equal(replay.execution.lease, null); + assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 1); + assert.equal(replay.journal.filter((record) => record.value.type === "edge-taken").length, 1); + assert.equal(replay.journal.filter((record) => record.value.type === "lease-released").length, 1); + assert.equal(invalidations.length, 1); + assert.equal(invalidations[0].projection.itemRef, fixtureItemRef); +}); diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index 81641f95..61cec148 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -211,6 +211,7 @@ test("oven list exposes the validated official catalog revision and origin", () assert.deepEqual(official.map(({ id }) => id), [ "checklist", "differential-testing", + "loop-progress", "model-lab", "performance-tracing", "streaming-diff", @@ -221,6 +222,7 @@ test("oven list exposes the validated official catalog revision and origin", () const expectedContracts = new Map([ ["checklist", "checklist-progress@1"], ["differential-testing", "burnlist-differential-testing-data@1"], + ["loop-progress", "checklist-progress@1"], ["model-lab", "burnlist-model-lab-data@1"], ["performance-tracing", "burnlist-differential-testing-data@1"], ["streaming-diff", "burnlist-streaming-diff-data@2"], diff --git a/src/cli/oven-use.mjs b/src/cli/oven-use.mjs index 757ece65..fc34e583 100644 --- a/src/cli/oven-use.mjs +++ b/src/cli/oven-use.mjs @@ -28,6 +28,17 @@ function adoptedOutput(saved, path) { return `Adopted Oven ${saved.id}@${saved.version} at ${path}`; } +function noExampleAdoptionOutput(saved, targetPath, repoRoot, catalogEntry) { + const adopted = `${adoptedOutput(saved, targetPath)}\nNo example/data.json is shipped; adopted without data.`; + // `routeKind` is catalog-owned semantics, not a presentation guess by id. + // Burnlist lenses are hydrated by core transport, so a hand-authored snapshot + // would be both unnecessary and misleading. + if (catalogEntry?.routeKind === "burnlist-lens") { + return `${adopted}\nData is supplied by Burnlist core transport; no local data action is needed.`; + } + return `${adopted}\nNext: burnlist oven set ${saved.id} --repo ${JSON.stringify(repoRoot)}`; +} + function eventWarning(error) { const detail = String(error?.message ?? error ?? "unknown error") .replace(/[\u0000-\u001f\u007f]+/gu, " ") @@ -86,7 +97,7 @@ export function useShippedOven({ publishAdoptionEvent(repoRoot, saved, timestamp, observerWarnings, publishDefinitionEvent); return { warnings: observerWarnings, - output: `${adoptedOutput(saved, targetPath)}\nNo example/data.json is shipped; adopted without data.\nNext: burnlist oven set ${saved.id} --repo ${JSON.stringify(repoRoot)}`, + output: noExampleAdoptionOutput(saved, targetPath, repoRoot, shipped.catalogEntry), }; } diff --git a/src/cli/oven-use.test.mjs b/src/cli/oven-use.test.mjs index 7b92c3d0..2a88b540 100644 --- a/src/cli/oven-use.test.mjs +++ b/src/cli/oven-use.test.mjs @@ -102,6 +102,18 @@ test("oven use adopts without data when no exact shipped example exists", (t) => assert.equal(existsSync(canonicalOvenDataPath(context.repo, "differential-testing")), false); }); +test("oven use leaves core-transport burnlist lenses unbound when no example is shipped", (t) => { + const context = fixture(t); + const result = runResult(context.repo, "oven", "use", "loop-progress", "--repo", context.repo); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(vendoredOvenPath(context.repo, "loop-progress")), true); + assert.equal(existsSync(canonicalOvenDataPath(context.repo, "loop-progress")), false); + assert.equal(Object.hasOwn(readBindingStore(context.repo).bindings, "loop-progress"), false); + assert.match(result.stdout, /Data is supplied by Burnlist core transport; no local data action is needed\./u); + assert.doesNotMatch(result.stdout, /burnlist oven set/u); +}); + test("oven use validates, transactionally installs, and renders exact example data", async (t) => { const context = fixture(t); const payload = checklistFixture; diff --git a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json index e39c9e23..259b5ca3 100644 --- a/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json +++ b/src/loops/__fixtures__/minimal-review-e2e-dom.golden.json @@ -1,8 +1,8 @@ [ { "checkpoint": "needs-human", - "domBytes": 9373, - "domSha256": "051bc5760c63e3dc8532e84778dc2b31d87b7674a45f0dbae9ab4c13fb2adea8" + "domBytes": 11342, + "domSha256": "c155b4665ec241609dc96740a8e3579bd37e0666fe33723e94ca1f9b7440360e" }, { "checkpoint": "paused", @@ -16,8 +16,8 @@ }, { "checkpoint": "converged", - "domBytes": 9373, - "domSha256": "507e6c935ee89bd163e26d015862cb0ff3e5bf7d0b970d21542e8ac4dde1a897" + "domBytes": 11336, + "domSha256": "c23001c3b556cf064bbbe4d80dce9f36c71b36defda458b773d9a4636fec5990" }, { "checkpoint": "post-completion", diff --git a/src/loops/adapters/codex-cli.mjs b/src/loops/adapters/codex-cli.mjs index 27aa10e5..4cf01538 100644 --- a/src/loops/adapters/codex-cli.mjs +++ b/src/loops/adapters/codex-cli.mjs @@ -4,7 +4,10 @@ import { isAbsolute } from "node:path"; import { requestedCodexIdentity, validateAgentProfile, validateCodexProbe } from "../agents/profile.mjs"; const MAX_OUTPUT_BYTES = 1048576; -const MAX_JSONL_LINE_BYTES = 65536; +// Codex may emit one bounded JSONL item containing a large tool/result payload. +// The aggregate cap is the memory/safety boundary; a smaller per-line cap makes +// valid provider output fail depending on event chunking. +const MAX_JSONL_LINE_BYTES = MAX_OUTPUT_BYTES; const DIRECT_GUARANTEES = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }); function fail(message, code = "ELOOP_CODEX_ADAPTER") { throw Object.assign(new Error(`Codex adapter: ${message}`), { code }); } diff --git a/src/loops/adapters/codex-cli.test.mjs b/src/loops/adapters/codex-cli.test.mjs index 8a1435f1..a7f19c34 100644 --- a/src/loops/adapters/codex-cli.test.mjs +++ b/src/loops/adapters/codex-cli.test.mjs @@ -14,6 +14,7 @@ if (mode === "nonzero") { process.exit(7); } if (mode === "bad") { process.stdout.write("not-json\\n"); process.exit(0); } if (mode === "invalid-utf8") { process.stdout.write(Buffer.from([255,10])); process.exit(0); } if (mode === "oversize") { process.stdout.write(Buffer.alloc(1048577,97)); process.exit(0); } +if (mode === "large-line") { process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"x".repeat(70000)}})+"\\n"); } const session = mode === "same-session" ? "same" : "fresh-" + process.pid; process.stdout.write(JSON.stringify({type:"thread.started",thread_id:session,model:args[args.indexOf("-m") + 1],version:"fake-1"})+"\\n"); process.stdout.write(JSON.stringify({type:"burnlist.capability",guarantees:{freshSession:"enforced",filesystemWriteDeny:"enforced",foregroundHandle:"enforced",cancellation:"enforced",lifecycle:"enforced"}})+"\\n"); @@ -75,6 +76,11 @@ test("JSONL is strict UTF-8 and usage overflow or absence is unavailable", async await withMode("bad", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); await withMode("invalid-utf8", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad bytes." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); await withMode("oversize", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Too much." }).completion, (error) => ["ELOOP_CODEX_OUTPUT_LIMIT", "ELOOP_CODEX_OUTPUT"].includes(error.code))); + await withMode("large-line", async () => { + const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Large valid line." }).completion; + assert.equal(result.outcome, "completed"); + assert.equal(result.events[0].item.text.length, 70000); + }); for (const mode of ["missing-usage", "overflow"]) await withMode(mode, async () => { const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Usage." }).completion; assert.equal(result.usage, null); assert.equal(result.usageStatus, "unavailable"); diff --git a/src/loops/adapters/normalized-invocation.mjs b/src/loops/adapters/normalized-invocation.mjs index c77c291a..67483391 100644 --- a/src/loops/adapters/normalized-invocation.mjs +++ b/src/loops/adapters/normalized-invocation.mjs @@ -1,9 +1,13 @@ import { startCodexInvocation } from "./codex-cli.mjs"; import { runTrustedCapability } from "../capabilities/runner.mjs"; import { parseBoundedObject } from "../contracts/contract.mjs"; +import { validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; const MAX_SUMMARY_BYTES = 1024; -const RESULT_TYPES = new Set(["complete", "approve", "reject", "escalate"]); +const AGENT_RESULT_TYPES = new Set(["complete", "approve", "reject", "escalate"]); +const FINAL_REPORT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "invocationId", "assignmentId", + "recipeRevision", "policyRevision", "inputCandidate", "outcome", "summary", "findings", "resolvedFindingIds"]; +const MAX_FINAL_BYTES = 65_536; const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; @@ -51,41 +55,61 @@ function finalPrompt(invocation, node, current) { return ["Burnlist Stage 1 invocation.", `run=${invocation.runId}`, `node=${node.id}`, `attempt=${invocation.attempt}`, `claim=${current.claimId}`, `invocation=${invocation.invocationId}`, `assignment=${current.assignmentId}`, `recipe=${current.recipeRevision}`, `policy=${current.policyRevision}`, `candidate=${current.inputCandidate}`, - `role=${node.role ?? "check"}`, "Your terminal response must be exactly one JSON object (no Markdown) with schema burnlist.agent-final@1, runId, nodeId, attempt, claimId, invocationId, assignmentId, recipeRevision, policyRevision, inputCandidate, outcome, summary.", + `role=${node.role ?? "check"}`, + "Burnlist owns graph routing and checks; host output is transport-only and idempotent by replaying the exact final report.", + "Report exactly one canonical object with this schema: burnlist.agent-final@1", + "Required report fields: schema, runId, nodeId, attempt, claimId, invocationId, assignmentId, recipeRevision, policyRevision, inputCandidate, outcome, summary, findings, resolvedFindingIds.", + "Do not include destination or any graph transitions; no host-selected edges are accepted.", + "Host result contracts are by node mode: task=>complete, review=>approve|reject|escalate.", + "Host claim envelopes are bounded; use exact prepared claim identity and expiry semantics from the transport contract.", + "Host telemetry is optional but, if provided, must use burnlist-loop-host-telemetry@1 with provenance=host-reported and non-negative token/time fields.", "FROZEN INSTRUCTIONS:", current.instructionBytes, "ASSIGNED ITEM:", current.itemText, - "CANDIDATE CONTEXT:", current.candidateContext, "REVIEW EVIDENCE:", JSON.stringify(current.reviewerEvidence)].join("\n"); + "CANDIDATE CONTEXT:", current.candidateContext, "REVIEW EVIDENCE:", JSON.stringify(current.reviewerEvidence), + "OPEN FINDINGS:", JSON.stringify(current.openFindings ?? [])].join("\n"); } function finalTexts(events) { const texts = []; + const finals = []; for (const event of events) { if (event?.type !== "item.completed" || !event.item || typeof event.item !== "object" || Array.isArray(event.item) || event.item.type !== "agent_message" || typeof event.item.text !== "string") continue; - texts.push(event.item.text); + const text = event.item.text; + try { + const value = parseBoundedObject(Buffer.from(text, "utf8"), { maximumBytes: MAX_FINAL_BYTES, maximumDepth: 2, label: "agent final" }); + if (value.schema === "burnlist.agent-final@1") finals.push(text); + } catch { + // preceding conversational messages are permitted. + } + texts.push(text); } if (!texts.length) fail("agent emitted no final message"); - return texts; + return { texts, finals }; } function agentResult(events, invocation, node, current) { - const texts = finalTexts(events), envelopes = []; + const { texts, finals } = finalTexts(events); + if (!finals.length) fail("malformed or missing terminal agent result"); + if (!finals.every((value) => value === finals[0])) fail("duplicate terminal reports differ"); + const envelopes = []; for (let index = 0; index < texts.length; index += 1) { try { - const value = parseBoundedObject(Buffer.from(texts[index], "utf8"), { maximumBytes: 65_536, maximumDepth: 2, label: "agent final" }); + const value = parseBoundedObject(Buffer.from(texts[index], "utf8"), { maximumBytes: MAX_FINAL_BYTES, maximumDepth: 2, label: "agent final" }); if (value.schema === "burnlist.agent-final@1") envelopes.push({ index, value }); - } catch { /* preceding conversational messages are permitted */ } + } catch { + // non-final conversational lines are tolerated. + } } - if (envelopes.length !== 1 || envelopes[0].index !== texts.length - 1) fail("malformed or ambiguous terminal agent result"); + if (envelopes.at(-1).index !== texts.length - 1 || envelopes.length !== finals.length) fail("malformed or ambiguous terminal agent result"); const result = envelopes[0].value; - const keys = ["schema", "runId", "nodeId", "attempt", "claimId", "invocationId", "assignmentId", - "recipeRevision", "policyRevision", "inputCandidate", "outcome", "summary"]; - if (!exact(result, keys) || result.schema !== "burnlist.agent-final@1" || result.runId !== invocation.runId + if (!exact(result, FINAL_REPORT_KEYS) || result.schema !== "burnlist.agent-final@1" || result.runId !== invocation.runId || result.nodeId !== node.id || result.attempt !== invocation.attempt || result.invocationId !== invocation.invocationId || result.claimId !== current.claimId || result.assignmentId !== current.assignmentId || result.recipeRevision !== current.recipeRevision || result.policyRevision !== current.policyRevision || result.inputCandidate !== current.inputCandidate - || !RESULT_TYPES.has(result.outcome) || typeof result.summary !== "string") fail("malformed or stale agent result"); + || !AGENT_RESULT_TYPES.has(result.outcome) || typeof result.summary !== "string") fail("malformed or stale agent result"); const allowed = node.mode === "task" ? result.outcome === "complete" : ["approve", "reject", "escalate"].includes(result.outcome); if (!allowed) fail("agent result is illegal for node"); - return { kind: result.outcome, summary: clean(result.summary) }; + return { kind: result.outcome, summary: clean(result.summary), outputBytes: Buffer.byteLength(texts.at(-1), "utf8"), + findings: result.findings, resolvedFindingIds: result.resolvedFindingIds }; } function boundedCompletion(handle, milliseconds) { if (!milliseconds) return handle.completion.then((value) => ({ value, timedOut: false })); @@ -118,12 +142,8 @@ export function createNormalizedInvocation({ repoRoot, routes, nodes, bindingFor || typeof startAgent !== "function" || typeof runCheck !== "function" || !Number.isSafeInteger(agentTimeoutMs) || agentTimeoutMs < 0 || agentTimeoutMs > 86_400_000) fail("invalid dispatcher input"); let active = null; - async function invoke(invocation) { - const node = nodes.get(invocation?.nodeId); - if (!invocation || typeof invocation !== "object" || !node || typeof node !== "object") fail("invalid invocation"); - let current; - try { current = binding(bindingFor(invocation, node)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } - if (node.mode === "review" && (!current.candidateContext || !current.reviewerEvidence.length)) + async function invokeBound(invocation, node, current, requireReviewEvidence = true) { + if (node.mode === "review" && (!current.candidateContext || requireReviewEvidence && !current.reviewerEvidence.length)) return Object.freeze({ kind: "error", summary: "review context is incomplete", outputBytes: 0 }); if (node.kind === "check") { try { @@ -149,12 +169,36 @@ export function createNormalizedInvocation({ repoRoot, routes, nodes, bindingFor if (completed.value.outcome === "cancelled") return Object.freeze({ kind: "cancelled", summary: "agent cancelled", outputBytes: 0 }); if (completed.value.outcome !== "completed") return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); if (node.mode === "review") boundaryCandidate(candidateForBoundary, current.inputCandidate); - const result = agentResult(completed.value.events, invocation, node, current); - return Object.freeze({ ...result, outputBytes: Buffer.byteLength(JSON.stringify(completed.value.events), "utf8") }); + return Object.freeze(agentResult(completed.value.events, invocation, node, current)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } finally { active = null; } } + async function invoke(invocation) { + const node = nodes.get(invocation?.nodeId); + if (!invocation || typeof invocation !== "object" || !node || typeof node !== "object") fail("invalid invocation"); + let current; + try { current = binding(bindingFor(invocation, node)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } + const result = await invokeBound(invocation, node, current); + const { findings: _findings, resolvedFindingIds: _resolved, ...normalized } = result; + return Object.freeze(normalized); + } + /** Executes the exact controller-prepared agent input; no second prompt binding is invented. */ + async function invokePrepared(envelopeBytes) { + const envelope = validateHostExecutionEnvelope(envelopeBytes), input = envelope.input.value; + const node = nodes.get(input.nodeId); + if (!node || node.kind !== "agent") fail("prepared invocation does not target an agent node"); + const current = { + claimId: input.claimId, assignmentId: input.assignmentId, recipeRevision: input.recipeRevision, + policyRevision: input.policyRevision, inputCandidate: input.inputCandidate, + instructionBytes: Buffer.from(input.instructionBytes, "base64").toString("utf8"), + itemText: input.itemText ? Buffer.from(input.itemText, "base64").toString("utf8") : "legacy prepared claim", + candidateContext: Buffer.from(input.candidateContext, "base64").toString("utf8"), reviewerEvidence: input.reviewerEvidence, + openFindings: input.openFindings ?? [], + }; + return invokeBound(input, node, current); + } Object.defineProperty(invoke, "cancel", { value: () => active?.cancel?.() === true, enumerable: false }); Object.defineProperty(invoke, "active", { get: () => active !== null, enumerable: false }); + Object.defineProperty(invoke, "invokePrepared", { value: invokePrepared, enumerable: false }); return invoke; } diff --git a/src/loops/adapters/normalized-invocation.test.mjs b/src/loops/adapters/normalized-invocation.test.mjs index 55683991..76b276d2 100644 --- a/src/loops/adapters/normalized-invocation.test.mjs +++ b/src/loops/adapters/normalized-invocation.test.mjs @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createNormalizedInvocation } from "./normalized-invocation.mjs"; +import { createDispatchAuthority, createInvocationInput } from "../contracts/agent-result.mjs"; +import { createHostExecutionEnvelope } from "../contracts/host-execution.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; const profile = { id: "fake" }; const routes = { implementation: { profile }, review: { profile: { id: "review" } } }; const call = Object.freeze({ runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); -const maker = Object.freeze({ id: "implement", kind: "agent", mode: "task", role: "maker", instructions: "make" }); -const reviewer = Object.freeze({ id: "review", kind: "agent", mode: "review", role: "reviewer", instructions: "review" }); +const maker = Object.freeze({ id: "implement", kind: "agent", mode: "task", execution: "managed", intelligence: "standard", role: "maker", instructions: "make" }); +const reviewer = Object.freeze({ id: "review", kind: "agent", mode: "review", execution: "managed", intelligence: "strong", role: "reviewer", instructions: "review" }); const check = Object.freeze({ id: "verify", kind: "check", capability: "repo-verify" }); const current = Object.freeze({ claimId: `cl1-sha256:${"1".repeat(64)}`, assignmentId: `as1-sha256:${"b".repeat(64)}`, @@ -18,7 +21,7 @@ function final(invocation, node, outcome = "complete", extra = {}) { return JSON.stringify({ schema: "burnlist.agent-final@1", runId: invocation.runId, nodeId: node.id, attempt: invocation.attempt, claimId: current.claimId, invocationId: invocation.invocationId, assignmentId: current.assignmentId, recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, - inputCandidate: current.inputCandidate, outcome, summary: "ok", ...extra }); + inputCandidate: current.inputCandidate, outcome, summary: "ok", findings: [], resolvedFindingIds: [], ...extra }); } function event(...texts) { return texts.map((text) => ({ type: "item.completed", item: { type: "agent_message", text } })); } function agent(events, outcome = "completed") { return () => ({ cancel() { return true; }, completion: Promise.resolve({ outcome, events }) }); } @@ -27,6 +30,21 @@ function dispatcher({ startAgent, runCheck, timeout = 0, bindingFor = () => curr candidateForBoundary, startAgent: startAgent ?? agent(event(final(call, maker))), runCheck: runCheck ?? (async () => ({ result: { outcome: "pass", inputCandidate: current.inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("check") })), agentTimeoutMs: timeout }); } +function preparedReview(reviewerEvidence) { + const binding = { runId: call.runId, nodeId: "review", attempt: 1, claimId: current.claimId, assignmentId: current.assignmentId, + invocationId: `iv1-sha256:${"c".repeat(64)}`, recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, + inputCandidate: current.inputCandidate }; + const instruction = Buffer.from(current.instructionBytes), input = createInvocationInput({ schema: "burnlist-loop-invocation-input@1", ...binding, + itemRevision: `id1-sha256:${"d".repeat(64)}`, execution: reviewer.execution, intelligence: reviewer.intelligence, instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), + mode: "review", role: "reviewer", authority: "read", legalOutcomes: ["approve", "reject", "escalate"], requires: [], + itemText: Buffer.from(current.itemText).toString("base64"), + candidateContext: Buffer.from(current.candidateContext).toString("base64"), reviewerEvidence }); + const authority = createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, + itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length }); + return createHostExecutionEnvelope({ schema: "burnlist-loop-host-execution@1", ...binding, issuedAt: 1, expiresAt: 3_601_000, + invocationInput: input.bytes.toString("base64"), dispatchAuthority: authority.bytes.toString("base64") }).bytes; +} + test("maps actual Codex agent-message finals for maker and fresh reviewer", async () => { const seen = []; const invoke = dispatcher({ startAgent: ({ profile: selected, prompt }) => { @@ -41,6 +59,17 @@ test("maps actual Codex agent-message finals for maker and fresh reviewer", asyn assert.deepEqual(seen, ["fake", "review"]); }); +test("budgets only the accepted terminal envelope, not provider event telemetry", async () => { + const envelope = final(call, maker); + const invoke = dispatcher({ startAgent: agent([ + { type: "provider.telemetry", payload: "x".repeat(300_000) }, + ...event(envelope), + ]) }); + const result = await invoke(call); + assert.equal(result.kind, "complete"); + assert.equal(result.outputBytes, Buffer.byteLength(envelope, "utf8")); +}); + test("rejects malformed or stale claim, revision, candidate, assignment, and invocation finals", async () => { for (const extra of [{ invocationId: "b".repeat(32) }, { claimId: `cl1-sha256:${"9".repeat(64)}` }, { recipeRevision: `er1-sha256:${"8".repeat(64)}` }, { policyRevision: `bp1-sha256:${"7".repeat(64)}` }, @@ -49,10 +78,19 @@ test("rejects malformed or stale claim, revision, candidate, assignment, and inv assert.equal((await invoke(call)).kind, "error"); } assert.equal((await dispatcher({ startAgent: agent(event("not json")) })(call)).kind, "error"); - assert.equal((await dispatcher({ startAgent: agent(event(final(call, maker), final(call, maker))) })(call)).kind, "error"); + assert.equal((await dispatcher({ startAgent: agent(event(final(call, maker), final(call, maker))) })(call)).kind, "complete"); assert.equal((await dispatcher({ bindingFor: () => ({ inputCandidate: current.inputCandidate }) })(call)).kind, "error"); }); +test("rejects duplicate terminal reports when report bytes differ", async () => { + const first = dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "first" }))) }) ; + const mismatch = dispatcher({ + startAgent: agent(event(final(call, maker, "complete", { summary: "first" }), final(call, maker, "complete", { summary: "second" }))), + })(call); + assert.equal((await first(call)).kind, "complete"); + assert.equal((await mismatch).kind, "error"); +}); + test("maps trusted checks including timeout and stale candidate without invoking an agent", async () => { const verify = { ...call, nodeId: "verify" }; assert.equal((await dispatcher()(verify)).kind, "pass"); @@ -83,6 +121,21 @@ test("agent deadline becomes lost if cancellation never settles", async () => { assert.deepEqual(await invoke(call), { kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); }); +test("prepared review dispatch keeps envelope evidence enforcement and exposes exact evidence", async () => { + const evidence = `artifact:sha256:${"e".repeat(64)}`; + let spawned = false; + const missing = dispatcher({ startAgent() { spawned = true; throw new Error("must not start"); } }); + assert.equal((await missing.invokePrepared(preparedReview([]))).kind, "error"); + assert.equal(spawned, false); + let prompt = ""; + const invoke = dispatcher({ startAgent: ({ prompt: value }) => { + prompt = value; + return { cancel() { return true; }, completion: Promise.resolve({ outcome: "cancelled", events: [] }) }; + } }); + assert.equal((await invoke.invokePrepared(preparedReview([evidence]))).kind, "cancelled"); + assert.match(prompt, new RegExp(evidence, "u")); +}); + test("summaries are valid UTF-8 and limited to 1024 bytes", async () => { const result = await dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "€".repeat(500) }))) })(call); assert.ok(Buffer.byteLength(result.summary, "utf8") <= 1024); diff --git a/src/loops/assignment/assignment.mjs b/src/loops/assignment/assignment.mjs index e7fafb42..23fa3d31 100644 --- a/src/loops/assignment/assignment.mjs +++ b/src/loops/assignment/assignment.mjs @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { closeSync, constants, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { closeSync, constants, fsyncSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { compileLoopPackage } from "../dsl/compile.mjs"; @@ -23,12 +23,32 @@ function packageDirectory(loop) { return resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops/review"); } export async function resolveBuiltin(loop) { + if (!loop.selector.startsWith("loop:builtin:")) fail(`installed Loop ${loop.selector} was not found`); const compiled = await compileLoopPackage(packageDirectory(loop)); if (!compiled.ok) fail(`installed Loop ${loop.selector} does not compile`); if (loop.executable && loop.executable !== compiled.revisions.executable) fail("LoopRef executable pin does not match current package"); return compiled; } +function localPackageDirectory(repoRoot, loop) { + const root = resolve(repoRoot), parts = [root, join(root, ".burnlist"), join(root, ".burnlist", "loops"), join(root, ".burnlist", "loops", loop.name)]; + for (const path of parts) { + let stat; try { stat = lstatSync(path); } catch { fail(`project Loop ${loop.selector} was not found`); } + if (stat.isSymbolicLink() || !stat.isDirectory()) fail(`project Loop ${loop.selector} has an unsafe package path`); + } + return parts.at(-1); +} + +/** Resolve one closed selector kind. Project packages are data-only compiler input. */ +export async function resolveLoopPackage({ repoRoot, loop }) { + if (loop.selector.startsWith("loop:builtin:")) return resolveBuiltin(loop); + const directory = localPackageDirectory(repoRoot, loop); + const compiled = await compileLoopPackage(directory, { loopFile: `${loop.name}.loop` }); + if (!compiled.ok) fail(`project Loop ${loop.selector} does not compile`); + if (loop.executable && loop.executable !== compiled.revisions.executable) fail("LoopRef executable pin does not match current package"); + return compiled; +} + function planPath(repoRoot, item) { const found = findBurnlistDir(repoRoot, item.burnlistId); if (found.lifecycle.folder !== "inprogress") fail(`burnlist ${item.burnlistId} is not inprogress`); @@ -58,7 +78,7 @@ export async function assignLoopItem({ repoRoot, itemRef, loopRef, prepared, sto const item = parseItemRef(itemRef), loop = parseLoopRef(loopRef); const token = prepared ?? prepareItemMutation({ repoRoot, itemRef: item.selector }); if (token.item?.selector !== item.selector) fail("prepared token item does not match"); - const compiled = await resolveBuiltin(loop); + const compiled = await resolveLoopPackage({ repoRoot, loop }); const target = planPath(repoRoot, item); const canonicalSelector = loop.selector; return withLock(target.directory, () => { const whole = readFileSync(target.path); const located = locateItemSpan(whole, item.itemId); diff --git a/src/loops/assignment/assignment.test.mjs b/src/loops/assignment/assignment.test.mjs index 86fb51fc..0497248e 100644 --- a/src/loops/assignment/assignment.test.mjs +++ b/src/loops/assignment/assignment.test.mjs @@ -31,6 +31,43 @@ test("selectors are closed, disjoint, and reject noncanonical ULID overflow", () assert.equal(parseRunRef("run:01arz3ndektsv4rrffq69g5fav").id.length, 26); for (const value of ["review", "item:260710-001#BUG-07", "run:81arz3ndektsv4rrffq69g5fav", "run:01ARZ3NDEKTSV4RRFFQ69G5FAV"]) assert.throws(() => parseRunRef(value), TypeError); assert.equal(selectorKind("loop:builtin:review"), "loop"); assert.equal(selectorKind(ref()), "item"); + assert.deepEqual(parseLoopRef("loop:project:whole-first"), { selector: "loop:project:whole-first", name: "whole-first", executable: null }); + for (const value of ["loop:project:../review", "loop:project:Review", "loop:local:review", "loop:project:review/extra"]) assert.throws(() => parseLoopRef(value), TypeError); +}); + +test("project Loop packages are contained, frozen, and show provenance drift without changing the item pin", async () => { + const context = fixture(); + try { + const packageDir = join(context.repo, ".burnlist", "loops", "whole-first"); + mkdirSync(packageDir, { recursive: true }); + const builtin = join(project, "loops", "review"); + writeFileSync(join(packageDir, "whole-first.loop"), readFileSync(join(builtin, "review.loop"))); + writeFileSync(join(packageDir, "instructions.md"), readFileSync(join(builtin, "instructions.md"))); + const result = await assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:project:whole-first" }); + assert.equal(result.selector, "loop:project:whole-first"); + const pinned = await resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }); + assert.equal(pinned.artifact.selector, "loop:project:whole-first"); + writeFileSync(join(packageDir, "instructions.md"), `${readFileSync(join(packageDir, "instructions.md"), "utf8")}\n`); + const drifted = await resolveLoopAuthority({ repoRoot: context.repo, selector: ref() }); + assert.equal(drifted.artifact.frozen.revisions.executable, pinned.artifact.frozen.revisions.executable); + assert.equal(drifted.executableDrift, true); + assert.notEqual(drifted.currentCompiled.revisions.package, pinned.artifact.frozen.revisions.package); + assert.match((await import("../view/render.mjs")).renderResolvedLoopView(drifted), /MODE: ITEM-PINNED/u); + } finally { context.cleanup(); } +}); + +test("project Loop resolver rejects symlinked and malformed contained packages", async () => { + const context = fixture(); + try { + const loops = join(context.repo, ".burnlist", "loops"), outside = join(dirname(context.repo), "outside-loop"); + mkdirSync(loops, { recursive: true }); mkdirSync(outside); + symlinkSync(outside, join(loops, "whole-first")); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:project:whole-first" }), /unsafe package path/u); + rmSync(join(loops, "whole-first")); mkdirSync(join(loops, "whole-first")); + writeFileSync(join(loops, "whole-first", "whole-first.loop"), "not XML\n"); + writeFileSync(join(loops, "whole-first", "instructions.md"), "# instructions\n"); + await assert.rejects(assignLoopItem({ repoRoot: context.repo, itemRef: ref(), loopRef: "loop:project:whole-first" }), /does not compile/u); + } finally { context.cleanup(); } }); test("assignment writes canonical bytes, stores artifact before one replacement, and unassign restores exact bytes", async () => { diff --git a/src/loops/assignment/item-metadata.mjs b/src/loops/assignment/item-metadata.mjs index e4a84edf..53f8f428 100644 --- a/src/loops/assignment/item-metadata.mjs +++ b/src/loops/assignment/item-metadata.mjs @@ -61,7 +61,7 @@ export function findLoopMetadata(spanResult) { const meta = metadataAt(lines, markers[0]); if (meta.endLine > endLine) fail("Loop metadata escapes item span"); if (!AS.test(meta.values["Assignment-Id"]) || !ER.test(meta.values["Execution-Revision"]) || !LP.test(meta.values["Package-Revision"])) fail("noncanonical Loop digest"); - if (!/^loop:builtin:[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(meta.values.Selector)) fail("noncanonical Loop selector"); + if (!/^loop:(?:builtin|project):[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(meta.values.Selector)) fail("noncanonical Loop selector"); return meta; } diff --git a/src/loops/assignment/resolver.mjs b/src/loops/assignment/resolver.mjs index a70b34b0..f5a2a80b 100644 --- a/src/loops/assignment/resolver.mjs +++ b/src/loops/assignment/resolver.mjs @@ -3,7 +3,7 @@ import { join } from "node:path"; import { findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { locateItemSpan, validateAssignedItem } from "./item-metadata.mjs"; -import { resolveBuiltin } from "./assignment.mjs"; +import { resolveLoopPackage } from "./assignment.mjs"; import { parseItemRef, parseLoopRef, parseRunRef, selectorKind } from "./selectors.mjs"; import { assignmentStore } from "./store.mjs"; @@ -26,12 +26,12 @@ export async function resolveLoopAuthority({ repoRoot, selector, runReader }) { catch (error) { fail("E_LOOP_SELECTOR_INVALID", error.message); } if (kind === "loop") { const loop = parseLoopRef(selector, { allowViewSugar: true }); let compiled; - try { compiled = await resolveBuiltin(loop); } catch (error) { fail("E_LOOP_MISSING", error.message); } + try { compiled = await resolveLoopPackage({ repoRoot, loop }); } catch (error) { fail("E_LOOP_MISSING", error.message); } return { authority: "UNPINNED", selector: loop.selector, compiled, executableRevision: compiled.revisions.executable }; } if (kind === "item") { const item = parseItemRef(selector), value = assigned(repoRoot, item); - let current = null; try { current = await resolveBuiltin(parseLoopRef(value.meta.Selector)); } catch { /* pin remains authoritative */ } + let current = null; try { current = await resolveLoopPackage({ repoRoot, loop: parseLoopRef(value.meta.Selector) }); } catch { /* pin remains authoritative */ } let artifact; try { artifact = assignmentStore(repoRoot).load(value.meta["Assignment-Id"]); } catch { pinUnavailable(value.meta, current); } if (artifact.itemRef !== item.selector || artifact.assignmentId !== value.meta["Assignment-Id"] || artifact.assignedItemDigest !== value.meta.assignedDigest || artifact.unassignedItemDigest !== value.meta.unassignedDigest || artifact.executionRevision !== value.meta["Execution-Revision"] || artifact.packageRevision !== value.meta["Package-Revision"]) pinUnavailable(value.meta, current); // The assignment artifact remains the only executable authority. The fresh diff --git a/src/loops/assignment/selectors.mjs b/src/loops/assignment/selectors.mjs index a33b2502..a0fd174f 100644 --- a/src/loops/assignment/selectors.mjs +++ b/src/loops/assignment/selectors.mjs @@ -1,7 +1,7 @@ import { RUN_REF } from "../run/run-ref.mjs"; const HEX = "[a-f0-9]{64}"; -const LOOP = new RegExp(`^loop:builtin:([a-z0-9]+(?:-[a-z0-9]+)*)(?:@(er1-sha256:${HEX}))?$`, "u"); +const LOOP = new RegExp(`^loop:(builtin|project):([a-z0-9]+(?:-[a-z0-9]+)*)(?:@(er1-sha256:${HEX}))?$`, "u"); const ITEM = /^item:([0-9]{6}-[0-9]{3})#([A-Za-z0-9][A-Za-z0-9._-]{0,63})$/u; function reject(label, value) { throw new TypeError(`Invalid ${label}: ${String(value)}`); } @@ -11,7 +11,7 @@ export function parseLoopRef(value, { allowViewSugar = false } = {}) { if (allowViewSugar && value === "review") return { selector: "loop:builtin:review", name: "review", executable: null }; const match = typeof value === "string" ? LOOP.exec(value) : null; if (!match) reject("LoopRef", value); - return { selector: `loop:builtin:${match[1]}`, name: match[1], executable: match[2] ?? null }; + return { selector: `loop:${match[1]}:${match[2]}`, name: match[2], executable: match[3] ?? null }; } export function parseItemRef(value) { diff --git a/src/loops/assignment/store.mjs b/src/loops/assignment/store.mjs index 9648aa9b..8930bba1 100644 --- a/src/loops/assignment/store.mjs +++ b/src/loops/assignment/store.mjs @@ -23,7 +23,7 @@ function manifest(record, recipe) { function parseManifest(bytes, id) { let value; try { value = JSON.parse(bytes.toString("utf8")); } catch { fail("manifest is not JSON"); } if (!exact(value, MANIFEST_KEYS) || !Buffer.from(`${JSON.stringify(value)}\n`).equals(bytes)) fail("manifest is not canonical"); - if (value.schema !== "burnlist-loop-assignment@1" || value.assignmentId !== id || !ITEM.test(value.itemRef) || !/^loop:builtin:[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.selector) || !/^er1-sha256:[a-f0-9]{64}$/u.test(value.executionRevision) || !/^lp1-sha256:[a-f0-9]{64}$/u.test(value.packageRevision) || !/^ls1-sha256:[a-f0-9]{64}$/u.test(value.sourceRevision) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.unassignedItemDigest) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.assignedItemDigest) || !DIGEST.test(value.frozenRecipeDigest) || !Number.isSafeInteger(value.frozenRecipeSize) || value.frozenRecipeSize < 1) fail("manifest has invalid bindings"); + if (value.schema !== "burnlist-loop-assignment@1" || value.assignmentId !== id || !ITEM.test(value.itemRef) || !/^loop:(?:builtin|project):[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.selector) || !/^er1-sha256:[a-f0-9]{64}$/u.test(value.executionRevision) || !/^lp1-sha256:[a-f0-9]{64}$/u.test(value.packageRevision) || !/^ls1-sha256:[a-f0-9]{64}$/u.test(value.sourceRevision) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.unassignedItemDigest) || !/^id1-sha256:[a-f0-9]{64}$/u.test(value.assignedItemDigest) || !DIGEST.test(value.frozenRecipeDigest) || !Number.isSafeInteger(value.frozenRecipeSize) || value.frozenRecipeSize < 1) fail("manifest has invalid bindings"); return value; } diff --git a/src/loops/completion/completion.mjs b/src/loops/completion/completion.mjs index c86d5172..ba2b5c36 100644 --- a/src/loops/completion/completion.mjs +++ b/src/loops/completion/completion.mjs @@ -1,6 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import { closeSync, constants, fsyncSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { checklistCompletion, findBurnlistDir, withLock } from "../../cli/lifecycle-moves.mjs"; import { localIsoTimestamp, parsePlan, validatePlan } from "../../server/plan-model.mjs"; import { publishOvenEvent } from "../../events/oven-event-store.mjs"; @@ -9,6 +9,7 @@ import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadat import { parseItemRef } from "../assignment/selectors.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { runStore } from "../run/run-store.mjs"; +import { deriveCandidate } from "../run/candidate.mjs"; const RECEIPT = "completion-receipt.json", INTENT = "completion-intent.json"; const SHA = /^[a-f0-9]{64}$/u, RUN = /^run:[0-9a-z]{26}$/u, ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; @@ -72,7 +73,7 @@ function assertApplied(plan, record) { const completed = plan.completed.filter((item) => item.id === itemId && item.completedAt === record.completedAt && item.title === record.title); if (completed.length !== 1) fail("receipt does not match the current Burnlist"); } -function assertCurrentAssignment({ repoRoot, planBytes, plan, authority, replay, store }) { +function assertCurrentAssignment({ repoRoot, planBytes, plan, authority, replay, store, candidateId }) { if (replay.projection.state !== "converged" || replay.projection.leaseHeld) fail("Run is not converged and idle", "ERUN_NOT_CONVERGED"); if (replay.projection.itemRef !== authority.itemRef || authority.runId !== replay.projection.runId) fail("Run authority does not match its journal"); const current = store.readCurrentRun?.(authority.itemRef); @@ -82,6 +83,7 @@ function assertCurrentAssignment({ repoRoot, planBytes, plan, authority, replay, catch { fail("assigned item no longer matches the Run", "ESTALE_ASSIGNMENT"); } if (metadata["Assignment-Id"] !== authority.assignmentId || artifact.assignmentId !== authority.assignmentId || artifact.itemRef !== authority.itemRef || artifact.assignedItemDigest !== authority.itemRevision || metadata.assignedDigest !== authority.itemRevision || artifact.executionRevision !== metadata["Execution-Revision"] || artifact.packageRevision !== metadata["Package-Revision"]) fail("assigned item no longer matches the Run", "ESTALE_ASSIGNMENT"); const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); if (JSON.stringify(frozen.ir) !== JSON.stringify(replay.graph)) fail("Run graph does not match its sealed assignment"); + if (!replay.execution.candidate || replay.execution.candidate.id !== candidateId) fail("Run candidate drifted before completion", "ECANDIDATE_DRIFT"); return { item, title: plan.items.find((entry) => entry.id === item.itemId)?.title }; } function assertAuthority(authority, runId) { @@ -94,6 +96,10 @@ function publish(repoRoot, outcome) { if (!outcome.applied) return; try { publis export function completeLoopRun({ repoRoot, runId, store = runStore(repoRoot), hooks = {} }) { if (!RUN.test(runId) || !store?.read || !store?.readAuthority || !store?.readCurrentRun || !store?.paths?.pathFor) fail("invalid completion input"); const replay = store.read(runId), authority = assertAuthority(store.readAuthority(runId), runId), item = parseItemRef(authority.itemRef), found = findBurnlistDir(repoRoot, item.burnlistId); + // Capture before the lifecycle lock: that lock is itself an untracked + // runtime artifact and must not manufacture candidate drift. + const candidateExclusions = [relative(resolve(repoRoot), join(found.dir, ".lock"))]; + const completionCandidate = deriveCandidate({ repoRoot, excludedPaths: candidateExclusions }); if (found.lifecycle.folder !== "inprogress") fail(`Burnlist ${item.burnlistId} is not inprogress`); const lifecycle = lifecycleIdentity(repoRoot, item.burnlistId, found.dir); const result = withLock(found.dir, () => { @@ -114,12 +120,17 @@ export function completeLoopRun({ repoRoot, runId, store = runStore(repoRoot), h } let record = readCanonical(intentPath, "completion intent"); if (record) { record = validRecord(record, "completion intent"); if (record.runId !== runId || record.itemRef !== authority.itemRef || record.assignmentId !== authority.assignmentId) fail("completion intent belongs to another Run"); const completed = plan.completed.filter((entry) => entry.id === item.itemId && entry.completedAt === record.completedAt && entry.title === record.title); if (completed.length === 1) { atomicWrite(receiptPath, Buffer.from(`${JSON.stringify(record)}\n`)); rmSync(intentPath, { force: true }); syncDirectory(runDir); return { applied: false, item: completed[0], record, event: null }; } } - const current = assertCurrentAssignment({ repoRoot, planBytes: bytes, plan, authority, replay, store }); if (!current.title) fail("Run item title is unavailable"); + const current = assertCurrentAssignment({ repoRoot, planBytes: bytes, plan, authority, replay, store, candidateId: completionCandidate.id }); if (!current.title) fail("Run item title is unavailable"); const completedAt = record?.completedAt ?? localIsoTimestamp(), provisional = recordFor({ authority, completedAt, title: current.title, planDigest: "0".repeat(64) }), next = completedMarkdown(plan.markdown, provisional); record = recordFor({ authority, completedAt, title: current.title, planDigest: digest(Buffer.from(next)) }); const existingIntent = readCanonical(intentPath, "completion intent"); if (existingIntent && validRecord(existingIntent, "completion intent").planDigest !== record.planDigest) fail("completion intent no longer matches the active Burnlist", "ESTALE_INTENT"); if (!existingIntent) { atomicWrite(intentPath, Buffer.from(`${JSON.stringify(record)}\n`)); hooks.afterIntent?.(record); } + hooks.beforePlan?.(record); + // This is the final pre-mutation boundary. The lifecycle lock is excluded + // from both captures because it is our own untracked synchronization file. + if (deriveCandidate({ repoRoot, excludedPaths: candidateExclusions }).id !== completionCandidate.id) + fail("Run candidate drifted before completion", "ECANDIDATE_DRIFT"); lifecycleIdentity(repoRoot, item.burnlistId, found.dir, lifecycle); const staged = atomicPlanWrite(planPath, Buffer.from(next)); hooks.afterPlan?.(record); atomicWrite(receiptPath, Buffer.from(`${JSON.stringify(record)}\n`)); hooks.afterReceipt?.(record); rmSync(intentPath, { force: true }); syncDirectory(runDir); return { applied: true, item: { id: item.itemId, title: current.title }, record, event: checklistCompletion(item.burnlistId, { id: item.itemId, title: current.title }, record.completedAt, staged) }; }); diff --git a/src/loops/completion/completion.test.mjs b/src/loops/completion/completion.test.mjs index a5b5561d..09db019b 100644 --- a/src/loops/completion/completion.test.mjs +++ b/src/loops/completion/completion.test.mjs @@ -27,7 +27,7 @@ async function converged(context, runId = fixtureRunId) { await createProductionRun({ repoRoot: context.repo, store, itemRef: fixtureItemRef, runId }); const counter = join(context.directory, `counter-${runId.slice(-4)}`); writeFileSync(counter, "0"); const before = [process.env.BURNLIST_FAKE_COUNTER, process.env.BURNLIST_FAKE_OUTCOMES]; - process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,approve"; + process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,complete,complete,approve,complete,approve"; try { assert.equal((await createStoredProductionRunRunner({ repoRoot: context.repo, store, runId }).run()).projection.state, "converged"); } finally { for (const [key, value] of [["BURNLIST_FAKE_COUNTER", before[0]], ["BURNLIST_FAKE_OUTCOMES", before[1]]]) { @@ -89,6 +89,24 @@ test("completion rejects a prepared Run and a stale assigned item", async (t) => assert.equal(completeLoopRun({ repoRoot: superseded.repo, runId: currentId, store: currentStore }).alreadyApplied, false); }); +test("candidate drift blocks completion but a fresh Run explicitly supersedes the stale convergence", async (t) => { + const value = context(t), store = await converged(value); + writeFileSync(join(value.repo, "src", "changed-after-convergence.txt"), "drift\n"); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /candidate drifted/u); + const fresh = "run:01arz3ndektsv4rrffq69g5faw"; + await createProductionRun({ repoRoot: value.repo, store, itemRef: fixtureItemRef, runId: fresh }); + assert.equal(store.readCurrentRun(fixtureItemRef).runId, fresh); +}); + +test("completion rechecks candidate after intent and before canonical plan mutation", async (t) => { + const value = context(t), store = await converged(value); + assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store, hooks: { + beforePlan() { writeFileSync(join(value.repo, "src", "drift-at-final-boundary.txt"), "drift\n"); }, + } }), /candidate drifted/u); + assert.match(readFileSync(value.planPath, "utf8"), /- \[ \] L29/u); + assert.equal(existsSync(runPath(store, fixtureRunId, "completion-intent.json")), true); +}); + test("receipt and Run ambiguity evidence fail closed when corrupt, bounded, symlinked, or duplicate", async (t) => { const value = context(t), store = await converged(value), receipt = runPath(store, fixtureRunId, "completion-receipt.json"); symlinkSync(value.planPath, receipt); assert.throws(() => completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }), /receipt is corrupt/u); rmSync(receipt); diff --git a/src/loops/contracts/agent-result.mjs b/src/loops/contracts/agent-result.mjs index 3e046dcc..75b4d63b 100644 --- a/src/loops/contracts/agent-result.mjs +++ b/src/loops/contracts/agent-result.mjs @@ -1,11 +1,18 @@ import { rawSha256 } from "../dsl/hash.mjs"; import { bindingsMatch, DIGESTS, exact, fail, identity, parseBoundedObject, parseResultBytes, SLUG, sortedUnique } from "./contract.mjs"; -import { nextOpenFindings, validateFindingSet } from "./finding.mjs"; +import { nextOpenFindings, validateFinding, validateFindingSet } from "./finding.mjs"; const RESULT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "outcome", "findings", "resolvedFindingIds"]; -const INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "instructionDigest", "instructionBytes", "candidateContext", "reviewerEvidence"]; +const INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "execution", "intelligence", "mode", "role", "authority", "legalOutcomes", "requires", "openFindings", "instructionDigest", "instructionBytes", "itemText", "candidateContext", "reviewerEvidence"]; +const PRE_FINDINGS_INPUT_KEYS = INPUT_KEYS.filter((key) => key !== "openFindings"); +const PRE_CONTEXT_INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "execution", "intelligence", "instructionDigest", "instructionBytes", "candidateContext", "reviewerEvidence"]; +const LEGACY_INPUT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "instructionDigest", "instructionBytes", "candidateContext", "reviewerEvidence"]; const AUTHORITY_KEYS = ["schema", "state", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "itemRevision", "inputSchema", "inputDigest", "inputByteLength"]; -const OUTCOMES = Object.freeze({ task: new Set(["complete"]), review: new Set(["approve", "reject", "escalate"]) }); +export const HOST_OUTCOMES = Object.freeze({ + task: Object.freeze(["complete"]), + review: Object.freeze(["approve", "reject", "escalate"]), +}); +const OUTCOMES = { task: new Set(HOST_OUTCOMES.task), review: new Set(HOST_OUTCOMES.review) }; const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; function base64(value, label, maximum) { @@ -19,6 +26,12 @@ function canonicalEnvelope(value) { schema: value.schema, runId: value.runId, nodeId: value.nodeId, attempt: value.attempt, claimId: value.claimId, assignmentId: value.assignmentId, invocationId: value.invocationId, recipeRevision: value.recipeRevision, policyRevision: value.policyRevision, inputCandidate: value.inputCandidate, itemRevision: value.itemRevision, + ...(Object.hasOwn(value, "execution") ? { execution: value.execution, intelligence: value.intelligence } : {}), + ...(Object.hasOwn(value, "itemText") ? { + mode: value.mode, role: value.role, authority: value.authority, legalOutcomes: value.legalOutcomes, + requires: value.requires, ...(Object.hasOwn(value, "openFindings") ? { openFindings: value.openFindings } : {}), + itemText: value.itemText, + } : {}), instructionDigest: value.instructionDigest, instructionBytes: value.instructionBytes, candidateContext: value.candidateContext, reviewerEvidence: value.reviewerEvidence, })}\n`, "utf8"); @@ -26,12 +39,30 @@ function canonicalEnvelope(value) { /** Builds the exact adapter stdin/prompt payload; this digest is persisted before dispatch by the runner. */ export function createInvocationInput(value) { - if (!exact(value, INPUT_KEYS) || value.schema !== "burnlist-loop-invocation-input@1") fail("invalid invocation input"); + if ((!exact(value, INPUT_KEYS) && !exact(value, PRE_FINDINGS_INPUT_KEYS) + && !exact(value, PRE_CONTEXT_INPUT_KEYS) && !exact(value, LEGACY_INPUT_KEYS)) + || value.schema !== "burnlist-loop-invocation-input@1") fail("invalid invocation input"); identity(value, "invocation input"); - if (!DIGESTS.item.test(value.itemRevision) || !DIGESTS.raw.test(value.instructionDigest) + if (!DIGESTS.item.test(value.itemRevision) || (Object.hasOwn(value, "execution") + && (!["managed", "host"].includes(value.execution) || !["fast", "standard", "strong", "critical"].includes(value.intelligence))) || !DIGESTS.raw.test(value.instructionDigest) || !SLUG.test(value.nodeId) || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 || !sortedUnique(value.reviewerEvidence) || !value.reviewerEvidence.every((ref) => DIGESTS.artifact.test(ref))) fail("invalid invocation evidence"); const instruction = base64(value.instructionBytes, "instruction bytes", 65_536); + if (Object.hasOwn(value, "itemText")) { + const expected = value.mode === "task" ? ["complete"] : value.mode === "review" ? ["approve", "reject", "escalate"] : null; + if (!expected || typeof value.role !== "string" || !value.role || Buffer.byteLength(value.role) > 64 + || !["write", "read"].includes(value.authority) || !Array.isArray(value.legalOutcomes) + || JSON.stringify(value.legalOutcomes) !== JSON.stringify(expected) + || !Array.isArray(value.requires) || value.requires.length > 50 || !sortedUnique(value.requires) + || value.requires.some((id) => typeof id !== "string" || !id || Buffer.byteLength(id) > 128 || /[\0\r\n]/u.test(id)) + || !base64(value.itemText, "item text", 65_536).length) + fail("invalid invocation execution contract"); + if (Object.hasOwn(value, "openFindings")) { + if (!Array.isArray(value.openFindings) || value.openFindings.length > 50) fail("invalid invocation open findings"); + const checked = value.openFindings.map(validateFinding); + if (!sortedUnique(checked.map((finding) => finding.id))) fail("invalid invocation open findings"); + } + } const context = base64(value.candidateContext, "candidate context", 65_536); if (!instruction.length || !context.length) fail("empty invocation input"); if (rawSha256(instruction) !== value.instructionDigest) fail("instruction digest does not match frozen bytes"); @@ -82,9 +113,9 @@ export function validateAgentResult(value, { mode, openFindings = new Map() } = if (!exact(value, RESULT_KEYS) || value.schema !== "agent-result@1") fail("invalid agent result"); identity(value, "agent result"); if (!OUTCOMES[mode]?.has(value.outcome)) fail("agent outcome is not allowed for node mode"); - const findings = validateFindingSet(value.findings, value.resolvedFindingIds, openFindings); - if (mode === "task" && (findings.findings.length || findings.resolvedFindingIds.length)) fail("task completion cannot carry findings"); - const next = nextOpenFindings(openFindings, findings); + if (mode === "task" && (value.findings.length || value.resolvedFindingIds.length)) fail("task completion cannot carry findings"); + const findings = validateFindingSet(value.findings, value.resolvedFindingIds, mode === "review" ? openFindings : new Map()); + const next = mode === "review" ? nextOpenFindings(openFindings, findings) : openFindings; if (mode === "review" && value.outcome === "approve") { if (findings.findings.some((finding) => finding.severity === "blocker" || finding.severity === "major") || [...openFindings.values()].some((finding) => (finding.severity === "blocker" || finding.severity === "major") && !findings.resolvedFindingIds.includes(finding.id)) diff --git a/src/loops/contracts/contracts.test.mjs b/src/loops/contracts/contracts.test.mjs index 5988fb3f..48904246 100644 --- a/src/loops/contracts/contracts.test.mjs +++ b/src/loops/contracts/contracts.test.mjs @@ -27,7 +27,7 @@ function check(outcome, extra = {}) { return { schema: "check-result@1", ...bind function bytes(value) { return Buffer.from(JSON.stringify(value)); } function invocation(extra = {}) { const instruction = Buffer.from("Follow the frozen instructions.\n"); - return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), instructionDigest: rawSha256(instruction), + return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), execution: "managed", intelligence: "strong", instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [], ...extra }; } function dispatch(input, extra = {}) { diff --git a/src/loops/contracts/host-execution.mjs b/src/loops/contracts/host-execution.mjs new file mode 100644 index 00000000..c2f127f6 --- /dev/null +++ b/src/loops/contracts/host-execution.mjs @@ -0,0 +1,123 @@ +import { bindingsMatch, exact, fail, identity, parseBoundedObject } from "./contract.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; +import { validateAgentResult, validateDispatchAuthority, validateInvocationInput } from "./agent-result.mjs"; + +const KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "assignmentId", "invocationId", "recipeRevision", "policyRevision", "inputCandidate", "issuedAt", "expiresAt", "invocationInput", "dispatchAuthority"]; +const REPORT_KEYS = ["schema", "result", "telemetry"]; +const TELEMETRY_KEYS = ["schema", "provenance", "executor", "displayName", "provider", "model", "effort", "startedAt", "completedAt", "inputTokens", "outputTokens"]; +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; +const MAX_ENVELOPE_BYTES = 400_000; +const MAX_LIFETIME_MS = 24 * 60 * 60 * 1000; + +function base64(value, label, maximum) { + if (typeof value !== "string" || !BASE64.test(value)) fail(`invalid ${label}`); + const bytes = Buffer.from(value, "base64"); + if (!bytes.length || bytes.length > maximum || bytes.toString("base64") !== value) fail(`invalid ${label}`); + return bytes; +} + +function timestamp(value, label) { + if (!Number.isSafeInteger(value) || value < 0 || value > 8_640_000_000_000_000) fail(`invalid ${label}`); + return value; +} + +function canonical(value) { + return Buffer.from(`${JSON.stringify({ + schema: value.schema, runId: value.runId, nodeId: value.nodeId, attempt: value.attempt, claimId: value.claimId, + assignmentId: value.assignmentId, invocationId: value.invocationId, recipeRevision: value.recipeRevision, + policyRevision: value.policyRevision, inputCandidate: value.inputCandidate, issuedAt: value.issuedAt, + expiresAt: value.expiresAt, invocationInput: value.invocationInput, dispatchAuthority: value.dispatchAuthority, + })}\n`, "utf8"); +} + +/** + * Canonical, controller-issued transport for one already-prepared agent claim. + * It deliberately contains neither an edge/destination nor a reported outcome: + * hosts execute the exact input, while Burnlist remains the transition authority. + */ +export function createHostExecutionEnvelope(value) { + if (!exact(value, KEYS) || value.schema !== "burnlist-loop-host-execution@1") fail("invalid host execution envelope"); + identity(value, "host execution envelope"); + const issuedAt = timestamp(value.issuedAt, "issuedAt"); + const expiresAt = timestamp(value.expiresAt, "expiresAt"); + if (expiresAt <= issuedAt || expiresAt - issuedAt > MAX_LIFETIME_MS) fail("invalid host execution expiry"); + const inputBytes = base64(value.invocationInput, "invocation input", 262_144); + const authorityBytes = base64(value.dispatchAuthority, "dispatch authority", 16_384); + const authority = validateDispatchAuthority(authorityBytes); + const input = validateInvocationInput(inputBytes, authorityBytes); + if (!bindingsMatch(value, input.value) || !bindingsMatch(value, authority.value)) fail("host execution envelope binding mismatch"); + const bytes = canonical(value); + if (bytes.length > MAX_ENVELOPE_BYTES) fail("host execution envelope exceeds bounds"); + return Object.freeze({ value: Object.freeze({ ...value }), bytes, digest: rawSha256(bytes), input, authority }); +} + +/** Strictly parses a bounded canonical envelope before a host may use its inputs. */ +export function validateHostExecutionEnvelope(bytes) { + const raw = Buffer.from(bytes); + const value = parseBoundedObject(raw, { maximumBytes: MAX_ENVELOPE_BYTES, maximumDepth: 1, label: "host execution envelope" }); + const built = createHostExecutionEnvelope(value); + if (!built.bytes.equals(raw)) fail("host execution envelope is not canonical"); + return built; +} + +export const HOST_EXECUTION_ENVELOPE_SCHEMA = "burnlist-loop-host-execution@1"; + +export function hostExecutionExpired(envelope, now) { + if (!Number.isSafeInteger(now) || now < 0 || now > 8_640_000_000_000_000) fail("invalid host execution clock"); + const value = envelope?.value ?? envelope; + return now >= createHostExecutionEnvelope(value).value.expiresAt; +} + +function optionalText(value, label) { + if (value === null) return null; + if (typeof value !== "string" || !value || Buffer.byteLength(value) > 256 || /[\0\r\n]/u.test(value)) fail(`invalid ${label}`); + return value; +} + +function optionalCount(value, label) { + if (value === null) return null; + if (!Number.isSafeInteger(value) || value < 0) fail(`invalid ${label}`); + return value; +} + +export function validateHostTelemetry(value) { + if (value === null) return null; + if (!exact(value, TELEMETRY_KEYS) || value.schema !== "burnlist-loop-host-telemetry@1" + || value.provenance !== "host-reported") fail("invalid host telemetry"); + const telemetry = { + schema: value.schema, provenance: value.provenance, executor: optionalText(value.executor, "executor"), + displayName: optionalText(value.displayName, "displayName"), provider: optionalText(value.provider, "provider"), + model: optionalText(value.model, "model"), effort: optionalText(value.effort, "effort"), + startedAt: optionalCount(value.startedAt, "startedAt"), completedAt: optionalCount(value.completedAt, "completedAt"), + inputTokens: optionalCount(value.inputTokens, "inputTokens"), outputTokens: optionalCount(value.outputTokens, "outputTokens"), + }; + if (telemetry.executor === null || (telemetry.startedAt === null) !== (telemetry.completedAt === null) + || telemetry.startedAt !== null && telemetry.completedAt < telemetry.startedAt) fail("invalid host telemetry timing"); + return Object.freeze(telemetry); +} + +function reportBytes(value) { + return Buffer.from(`${JSON.stringify({ schema: value.schema, result: value.result, telemetry: value.telemetry })}\n`, "utf8"); +} + +/** A host reports evidence and an allowed outcome; it never reports an edge destination. */ +export function createHostExecutionReport(value, { envelope, mode, openFindings = new Map() } = {}) { + if (!exact(value, REPORT_KEYS) || value.schema !== "burnlist-loop-host-report@1" || !envelope?.value) fail("invalid host execution report"); + const result = validateAgentResult(value.result, { mode, openFindings }); + if (!bindingsMatch(result, envelope.value)) fail("host execution report binding mismatch"); + const telemetry = validateHostTelemetry(value.telemetry); + const report = Object.freeze({ schema: value.schema, result, telemetry }); + const bytes = reportBytes(report); + if (bytes.length > 262_144) fail("host execution report exceeds bounds"); + return Object.freeze({ value: report, bytes, digest: rawSha256(bytes) }); +} + +export function validateHostExecutionReport(bytes, options) { + const raw = Buffer.from(bytes); + const value = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 5, label: "host execution report" }); + const built = createHostExecutionReport(value, options); + if (!built.bytes.equals(raw)) fail("host execution report is not canonical"); + return built; +} + +export const HOST_EXECUTION_REPORT_SCHEMA = "burnlist-loop-host-report@1"; diff --git a/src/loops/contracts/host-execution.test.mjs b/src/loops/contracts/host-execution.test.mjs new file mode 100644 index 00000000..4770500b --- /dev/null +++ b/src/loops/contracts/host-execution.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDispatchAuthority, createInvocationInput } from "./agent-result.mjs"; +import { + createHostExecutionEnvelope, createHostExecutionReport, validateHostExecutionEnvelope, + hostExecutionExpired, validateHostExecutionReport, +} from "./host-execution.mjs"; +import { rawSha256 } from "../dsl/hash.mjs"; + +const hex = (letter) => letter.repeat(64); +const digest = (prefix, letter) => `${prefix}:${hex(letter)}`; +const binding = Object.freeze({ runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "implement", attempt: 1, + claimId: digest("cl1-sha256", "1"), assignmentId: digest("as1-sha256", "2"), invocationId: digest("iv1-sha256", "3"), + recipeRevision: digest("er1-sha256", "4"), policyRevision: digest("bp1-sha256", "5"), inputCandidate: digest("cm1-sha256", "6") }); + +function fixture(patch = {}) { + const instruction = Buffer.from("Implement the frozen task.\n"); + const input = createInvocationInput({ schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: digest("id1-sha256", "7"), execution: "host", intelligence: "fast", + mode: "task", role: "maker", authority: "write", legalOutcomes: ["complete"], requires: [], + instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), + itemText: Buffer.from("- [ ] H1 | Implement host execution\n").toString("base64"), + candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [] }); + const authority = createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, + itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length }); + return { schema: "burnlist-loop-host-execution@1", ...binding, issuedAt: 1_000, expiresAt: 1_801_000, + invocationInput: input.bytes.toString("base64"), dispatchAuthority: authority.bytes.toString("base64"), ...patch }; +} + +test("host execution envelope canonically binds the exact prepared invocation", () => { + const built = createHostExecutionEnvelope(fixture()); + assert.equal(built.bytes.toString(), `${JSON.stringify(fixture())}\n`); + const parsed = validateHostExecutionEnvelope(built.bytes); + assert.equal(parsed.digest, built.digest); + assert.equal(parsed.input.value.claimId, binding.claimId); + assert.equal(parsed.authority.value.inputDigest, parsed.input.digest); + assert.equal(Buffer.from(parsed.input.value.itemText, "base64").toString(), "- [ ] H1 | Implement host execution\n"); + assert.deepEqual({ mode: parsed.input.value.mode, role: parsed.input.value.role, authority: parsed.input.value.authority, + legalOutcomes: parsed.input.value.legalOutcomes }, { mode: "task", role: "maker", authority: "write", legalOutcomes: ["complete"] }); +}); + +test("pre-H6 host envelopes retain their legacy invocation bytes", () => { + const instruction = Buffer.from("Implement the frozen task.\n"); + const input = createInvocationInput({ schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: digest("id1-sha256", "7"), + instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), + candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [] }); + assert.equal(Object.hasOwn(input.value, "execution"), false); + const authority = createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, + itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length }); + const envelope = createHostExecutionEnvelope({ schema: "burnlist-loop-host-execution@1", ...binding, issuedAt: 1_000, expiresAt: 1_801_000, + invocationInput: input.bytes.toString("base64"), dispatchAuthority: authority.bytes.toString("base64") }); + assert.deepEqual(validateHostExecutionEnvelope(envelope.bytes).input.bytes, input.bytes); +}); + +test("host envelope rejects identity fabrication, noncanonical input bytes, and host transition fields", () => { + assert.throws(() => createHostExecutionEnvelope(fixture({ claimId: digest("cl1-sha256", "9") })), /binding mismatch/u); + assert.throws(() => createHostExecutionEnvelope({ ...fixture(), destination: "burn" }), /invalid host execution envelope/u); + assert.throws(() => createHostExecutionEnvelope({ ...fixture(), outcome: "complete" }), /invalid host execution envelope/u); + const value = fixture(); value.invocationInput = `${value.invocationInput} `; + assert.throws(() => createHostExecutionEnvelope(value), /invalid invocation input/u); +}); + +test("host envelope is bounded, canonical, and has a finite ordered lease", () => { + const built = createHostExecutionEnvelope(fixture()); + assert.throws(() => validateHostExecutionEnvelope(Buffer.concat([built.bytes, Buffer.from(" ")])), /canonical/u); + assert.equal(hostExecutionExpired(built, 1_800_999), false); + assert.equal(hostExecutionExpired(built, 1_801_000), true); + assert.throws(() => createHostExecutionEnvelope(fixture({ expiresAt: 1_000 })), /expiry/u); + assert.throws(() => createHostExecutionEnvelope(fixture({ expiresAt: 86_401_001 })), /expiry/u); + assert.throws(() => createHostExecutionEnvelope(fixture({ issuedAt: "1000" })), /issuedAt/u); + assert.throws(() => validateHostExecutionEnvelope(Buffer.alloc(400_001, 32)), /bounds/u); +}); + +function report(envelope, patch = {}) { + return { + schema: "burnlist-loop-host-report@1", + result: { + schema: "agent-result@1", ...binding, outcome: "complete", findings: [], resolvedFindingIds: [], + }, + telemetry: { + schema: "burnlist-loop-host-telemetry@1", provenance: "host-reported", executor: "claude-native", + displayName: "H1 implement", provider: "anthropic", model: "fast", effort: "low", + startedAt: 1000, completedAt: 2000, inputTokens: null, outputTokens: null, + }, + ...patch, + }; +} + +test("host report binds a legal result and explicitly host-reported telemetry", () => { + const envelope = createHostExecutionEnvelope(fixture()); + const built = createHostExecutionReport(report(envelope), { envelope, mode: "task" }); + assert.equal(validateHostExecutionReport(built.bytes, { envelope, mode: "task" }).digest, built.digest); + assert.equal(built.value.telemetry.provenance, "host-reported"); +}); + +test("host report rejects transition choice, stale identity, illegal outcome, and false telemetry", () => { + const envelope = createHostExecutionEnvelope(fixture()); + assert.throws(() => createHostExecutionReport({ ...report(envelope), destination: "burn" }, { envelope, mode: "task" }), /invalid host execution report/u); + const stale = report(envelope); stale.result = { ...stale.result, claimId: digest("cl1-sha256", "9") }; + assert.throws(() => createHostExecutionReport(stale, { envelope, mode: "task" }), /binding mismatch/u); + const illegal = report(envelope); illegal.result = { ...illegal.result, outcome: "approve" }; + assert.throws(() => createHostExecutionReport(illegal, { envelope, mode: "task" }), /not allowed/u); + const telemetry = report(envelope); telemetry.telemetry = { ...telemetry.telemetry, provenance: "managed" }; + assert.throws(() => createHostExecutionReport(telemetry, { envelope, mode: "task" }), /host telemetry/u); + const backwards = report(envelope); backwards.telemetry = { ...backwards.telemetry, completedAt: 999 }; + assert.throws(() => createHostExecutionReport(backwards, { envelope, mode: "task" }), /timing/u); +}); + +test("host report bytes make retransmission identity explicit", () => { + const envelope = createHostExecutionEnvelope(fixture()); + const first = createHostExecutionReport(report(envelope), { envelope, mode: "task" }); + const duplicate = validateHostExecutionReport(first.bytes, { envelope, mode: "task" }); + assert.equal(duplicate.digest, first.digest); + const changed = createHostExecutionReport(report(envelope, { telemetry: null }), { envelope, mode: "task" }); + assert.notEqual(changed.digest, first.digest); + assert.throws(() => validateHostExecutionReport(Buffer.concat([first.bytes, Buffer.from(" ")]), { envelope, mode: "task" }), /canonical/u); +}); diff --git a/src/loops/dsl/__fixtures__/review.ir.json b/src/loops/dsl/__fixtures__/review.ir.json index 845b08e9..00ec4a77 100644 --- a/src/loops/dsl/__fixtures__/review.ir.json +++ b/src/loops/dsl/__fixtures__/review.ir.json @@ -1 +1 @@ -{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"implement","budget":{"maxRounds":3,"maxMinutes":60,"maxAgentRuns":6,"maxCheckRuns":3,"maxTransitions":16,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"implement","mode":"task","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["verify","review"]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"verify","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"implement","on":"complete","to":"verify","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"review","on":"approve","to":"converged","maxVisits":null},{"from":"review","on":"reject","to":"implement","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"verify","on":"pass","to":"review","maxVisits":null},{"from":"verify","on":"fail","to":"implement","maxVisits":3}],"instructions":[{"id":"implement","digest":"sha256:8d1db5c7cbc11075fccc565180374e1929a8cf22683cbca3dde9b77ef667c0a8","byteLength":114},{"id":"review","digest":"sha256:53ca65be9491688119b5cbf2443d0b9aff003764db9e91657ec54dbae12d76aa","byteLength":119}]} +{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"start","budget":{"maxRounds":12,"maxMinutes":60,"maxAgentRuns":18,"maxCheckRuns":8,"maxTransitions":40,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"start","mode":"task","execution":"managed","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"start","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["final-validate","final-review"]},{"kind":"agent","id":"decompose","mode":"task","execution":"managed","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"decompose","independentFrom":null,"requires":[]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"agent","id":"final-review","mode":"review","execution":"managed","intelligence":"critical","role":"reviewer","route":"review.strong","authority":"read","instructions":"final-review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"check","id":"final-validate","capability":"repo-verify"},{"kind":"agent","id":"implement","mode":"task","execution":"managed","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"agent","id":"integrate","mode":"task","execution":"managed","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"integrate","independentFrom":null,"requires":[]},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","execution":"managed","intelligence":"strong","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"validate","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"start","on":"complete","to":"decompose","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"decompose","on":"complete","to":"implement","maxVisits":null},{"from":"final-review","on":"approve","to":"converged","maxVisits":null},{"from":"final-review","on":"reject","to":"decompose","maxVisits":3},{"from":"final-review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"final-validate","on":"pass","to":"final-review","maxVisits":null},{"from":"final-validate","on":"fail","to":"decompose","maxVisits":3},{"from":"implement","on":"complete","to":"validate","maxVisits":null},{"from":"integrate","on":"complete","to":"final-validate","maxVisits":null},{"from":"review","on":"approve","to":"integrate","maxVisits":null},{"from":"review","on":"reject","to":"decompose","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"validate","on":"pass","to":"review","maxVisits":null},{"from":"validate","on":"fail","to":"decompose","maxVisits":3}],"instructions":[{"id":"decompose","digest":"sha256:266f954460a1d833b73ae53b696166acb6b6c49536518f9af86467bf66046791","byteLength":253},{"id":"final-review","digest":"sha256:24c8c2c2574dbbe56732b94e097e35af6e60ea2406feb2ad0019bc28f41b5da8","byteLength":66},{"id":"implement","digest":"sha256:3aea4bc6d773f5b7b1a3e5899bd5be2cbce3aa2313a656e18d6444f244959e93","byteLength":246},{"id":"integrate","digest":"sha256:188626485e23cf2a78105095304fa336f65044d6a7e7559cfa97d804f15be07e","byteLength":236},{"id":"review","digest":"sha256:89a11f423ae87b2c86749cafa2bb956ab7ca7980fafc71aeec408ddc463024aa","byteLength":72},{"id":"start","digest":"sha256:106187b048c9e12f748dac6fe70f3c18a7579a56e9a62be9661bb8ea4e627c43","byteLength":240}]} diff --git a/src/loops/dsl/__fixtures__/review.revisions.json b/src/loops/dsl/__fixtures__/review.revisions.json index 75e6d569..1eef9d69 100644 --- a/src/loops/dsl/__fixtures__/review.revisions.json +++ b/src/loops/dsl/__fixtures__/review.revisions.json @@ -1 +1 @@ -{"source":"ls1-sha256:a2b31c7cb4f8025516aa51a789a0545d3224b3abc142d18c2ec86291c009c918","package":"lp1-sha256:2f6213c0b5d8e4c6da58d8e8ab227462bf075c672252dc4a30c2736de51d4726","executable":"er1-sha256:8e58f16abc427528e6e10048f80c67f38afcdc7bb5b8f626a1e93b314feba08d"} +{"source":"ls1-sha256:dc05c650d52c3fe7d48ffb39cf0a9c771ee4d591b09b4e8ba7f83737bf02d087","package":"lp1-sha256:1cad5c04ad4406a80c6faa76531a9c1e6f75d1a7a1b5e7636aeafd2e0ed2a443","executable":"er1-sha256:95d15ba7bc8e46100fc829236049993d6f08b5b32e8d512f04d0cb149a79e3ed"} diff --git a/src/loops/dsl/canonical.mjs b/src/loops/dsl/canonical.mjs index 5281b367..05eb88e5 100644 --- a/src/loops/dsl/canonical.mjs +++ b/src/loops/dsl/canonical.mjs @@ -1,6 +1,7 @@ import { compareUtf8 } from "./diagnostics.mjs"; -const nodeKeyOrder = { agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const nodeKeyOrder = { agent: ["kind", "id", "mode", "execution", "intelligence", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const legacyNodeKeyOrder = { ...nodeKeyOrder, agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"] }; const topOrder = ["schema", "compiler", "id", "declaredVersion", "entry", "budget", "nodes", "failurePolicy", "edges", "instructions"]; const budgetOrder = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; const policyOrder = ["error", "timeout", "cancelled", "lost", "exhausted"]; @@ -27,7 +28,7 @@ function encode(value, context = "") { if (typeof value === "number") { if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("Canonical numbers must be safe unsigned integers"); return String(value); } if (Array.isArray(value)) return `[${value.map((item) => encode(item)).join(",")}]`; if (!value || typeof value !== "object") throw new TypeError("Canonical IR contains an invalid value"); - if (context === "nodes[]") return object(value, nodeKeyOrder[value.kind]); + if (context === "nodes[]") return object(value, (value.kind === "agent" && !Object.hasOwn(value, "execution")) ? legacyNodeKeyOrder[value.kind] : nodeKeyOrder[value.kind]); if (context === "budget") return object(value, budgetOrder); if (context === "failurePolicy") return object(value, policyOrder); if (context === "edges[]") return object(value, edgeOrder); diff --git a/src/loops/dsl/compile.mjs b/src/loops/dsl/compile.mjs index ba004ff9..953ce42a 100644 --- a/src/loops/dsl/compile.mjs +++ b/src/loops/dsl/compile.mjs @@ -17,8 +17,8 @@ function appendDiagnostics(target, source) { } } -function sourceChecks(bytes, path, d) { - const [minimum, maximum] = sizes[path]; +function sourceChecks(bytes, path, d, limits = sizes) { + const [minimum, maximum] = limits[path]; if (bytes.length < minimum || bytes.length > maximum) d.add(path, 0, "E_FILE_SIZE", `${path} must contain ${minimum}..${maximum} bytes`); try { const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); @@ -40,7 +40,8 @@ function cloneFiles(data) { return Object.fromEntries(Object.entries(data).map(([path, bytes]) => [path, Buffer.from(bytes)])); } -function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { +function compileLoopFilesInternal(files, { continueOnMissing = false, loopFile = "review.loop" } = {}) { + const packagePaths = [loopFile, "instructions.md", "example/item.md"], packageSizes = { ...sizes, [loopFile]: [1, 65536] }; const d = createDiagnostics(); if (!files || typeof files !== "object" || Array.isArray(files)) { d.add("", 0, "E_PACKAGE", "Package files must be an object"); @@ -48,26 +49,28 @@ function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { const actual = Object.keys(files ?? {}); if (actual.length > 3) d.add("", 0, "E_PACKAGE_COUNT", "Package may contain at most three files"); - for (const path of actual) if (!paths.includes(path)) d.add(path, 0, "E_PACKAGE_PATH", "Unknown package file"); - for (const path of paths.slice(0, 2)) if (!(path in (files ?? {}))) d.add(path, 0, "E_PACKAGE_MISSING", "Required package file is missing"); + for (const path of actual) if (!packagePaths.includes(path)) d.add(path, 0, "E_PACKAGE_PATH", "Unknown package file"); + for (const path of packagePaths.slice(0, 2)) if (!(path in (files ?? {}))) d.add(path, 0, "E_PACKAGE_MISSING", "Required package file is missing"); if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all }; const data = {}; - for (const path of paths) if (path in (files ?? {})) { + for (const path of packagePaths) if (path in (files ?? {})) { try { data[path] = Buffer.from(files[path]); - sourceChecks(data[path], path, d); + const [minimum, maximum] = packageSizes[path]; + if (Buffer.from(data[path]).length < minimum || Buffer.from(data[path]).length > maximum) d.add(path, 0, "E_FILE_SIZE", `${path} must contain ${minimum}..${maximum} bytes`); + else sourceChecks(data[path], path, d, packageSizes); } catch { d.add(path, 0, "E_PACKAGE_BYTES", "Package file must be byte data"); } } - if (!("review.loop" in data)) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; + if (!(loopFile in data)) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; if (Object.values(data).reduce((sum, bytes) => sum + bytes.length, 0) > totalLimit) d.add("", 0, "E_PACKAGE_SIZE", "Package exceeds 393216 byte limit"); if (!continueOnMissing && d.all.length) return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; - const parsed = parseLoopXml(data["review.loop"]); + const parsed = parseLoopXml(data[loopFile]); const checked = parsed.ast ? validateLoop(parsed.ast) : null; appendDiagnostics(d, collectDiagnostics(parsed, checked)); @@ -86,13 +89,13 @@ function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { const ir = { ...checked.ir, instructions: extracted.sections.map(({ bytes, ...section }) => section) }; if (!validateClosedIr(ir)) { - d.add("review.loop", 0, "E_IR_INVARIANT", "Closed invariant validation failed"); + d.add(loopFile, 0, "E_IR_INVARIANT", "Closed invariant validation failed"); return { ok: false, diagnostics: d.all, packageFiles: cloneFiles(data) }; } const irBytes = canonicalIrBytes(ir); const revisions = { - source: prefixed("ls1-sha256:", "source-v1", [data["review.loop"]]), + source: prefixed("ls1-sha256:", "source-v1", [data[loopFile]]), package: packageRevision(data), executable: prefixed("er1-sha256:", "recipe-v1", [Buffer.from(ir.compiler), irBytes, ...extracted.sections.flatMap((section) => [Buffer.from(section.id), section.bytes])]), }; @@ -105,13 +108,13 @@ function compileLoopFilesInternal(files, { continueOnMissing = false } = {}) { instructions: extracted.sections, packageFiles, revisions, - rawSourceDigest: rawSha256(data["review.loop"]), + rawSourceDigest: rawSha256(data[loopFile]), diagnostics: d.all, }; } -export function compileLoopFiles(files) { - const result = compileLoopFilesInternal(files); +export function compileLoopFiles(files, options = {}) { + const result = compileLoopFilesInternal(files, options); const finalized = finalizeDiagnostics(result.diagnostics); if (finalized.length) return { ok: false, diagnostics: finalized }; return { ...result, diagnostics: finalized }; @@ -120,12 +123,12 @@ export function compileLoopFiles(files) { /** * Compile package files directly from a directory without trust or execution. */ -export async function compileLoopPackage(directory, { beforeLeafRead, afterLeafOpenForTest } = {}) { +export async function compileLoopPackage(directory, { beforeLeafRead, afterLeafOpenForTest, loopFile = "review.loop" } = {}) { const { files, diagnostics } = await readPackageDirectory(directory, { beforeLeafRead, - afterLeafOpenForTest, + afterLeafOpenForTest, loopFile, }); - const compiled = compileLoopFilesInternal(files, { continueOnMissing: true }); + const compiled = compileLoopFilesInternal(files, { continueOnMissing: true, loopFile }); const merged = finalizeDiagnostics([...diagnostics, ...(compiled.diagnostics ?? [])]); if (merged.length) return { ok: false, diagnostics: merged }; return compiled; diff --git a/src/loops/dsl/compile.test.mjs b/src/loops/dsl/compile.test.mjs index 45cf88da..fabfca88 100644 --- a/src/loops/dsl/compile.test.mjs +++ b/src/loops/dsl/compile.test.mjs @@ -5,6 +5,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { compileLoopFiles, compileLoopPackage } from "./compile.mjs"; import { freezeRecipe, loadFrozenRecipe } from "./frozen.mjs"; +import { canonicalIrBytes } from "./canonical.mjs"; +import { prefixed } from "./hash.mjs"; +import { validateClosedIr, validateReplayIr } from "./ir-validate.mjs"; +import { createJournalRecord } from "../run/run-journal.mjs"; +import { foldRun } from "../run/run-fold.mjs"; +import { created } from "../run/m2-test-fixtures.mjs"; import { renderDiagnostics } from "./diagnostics.mjs"; const root = new URL("../../../loops/review/", import.meta.url); @@ -19,8 +25,10 @@ test("built-in review package compiles to deterministic canonical frozen IR", as assert.deepEqual(first.irBytes, second.irBytes); assert.deepEqual(first.revisions, second.revisions); assert.equal(first.ir.schema, "burnlist-loop-ir@1"); assert.equal(first.ir.compiler, "burnlist-loop-compiler@1"); - assert.deepEqual(first.ir.nodes.map((node) => node.id), ["implement", "completed", "converged", "exhausted", "failed", "needs-human", "review", "stopped", "verify"]); - assert.deepEqual(first.ir.edges.map((edge) => edge.from), ["implement", "converged", "converged", "review", "review", "review", "verify", "verify"]); + assert.equal(first.ir.nodes.length, 14); + assert.equal(first.ir.edges.length, 16); + assert.deepEqual([...first.ir.nodes.map((node) => node.id)].sort(), ["completed", "converged", "decompose", "exhausted", "failed", "final-review", "final-validate", "implement", "integrate", "needs-human", "review", "start", "stopped", "validate"].sort()); + assert.deepEqual([...first.ir.edges.map((edge) => edge.from)].sort(), ["start", "decompose", "implement", "validate", "validate", "review", "review", "review", "integrate", "final-validate", "final-validate", "final-review", "final-review", "final-review", "converged", "converged"].sort()); assert.match(first.revisions.executable, /^er1-sha256:[a-f0-9]{64}$/); assert.equal((await compileLoopPackage(new URL("../../../loops/review", import.meta.url).pathname)).ok, true); }); @@ -32,13 +40,35 @@ test("runtime consumes persisted frozen IR and validates recipe identity", async assert.throws(() => loadFrozenRecipe(Buffer.from(changed)), /Frozen recipe/); }); +test("pre-H6 frozen recipes and Run journals replay without rewriting their identity", async () => { + const current = await compiled(), frozen = JSON.parse(freezeRecipe(current)); + const legacyIr = { + ...current.ir, + nodes: current.ir.nodes.map(({ execution: _execution, intelligence: _intelligence, ...node }) => node), + }; + assert.equal(validateClosedIr(legacyIr), false); + assert.equal(validateReplayIr(legacyIr), true); + const instructions = frozen.instructions; + const executable = prefixed("er1-sha256:", "recipe-v1", [ + Buffer.from(legacyIr.compiler), canonicalIrBytes(legacyIr), + ...instructions.flatMap((section) => [Buffer.from(section.id), Buffer.from(section.bytes, "base64")]), + ]); + const legacy = { ...frozen, revisions: { ...frozen.revisions, executable }, ir: legacyIr }; + const bytes = Buffer.from(`${JSON.stringify(legacy)}\n`); + const loaded = loadFrozenRecipe(bytes); + assert.equal(loaded.irBytes, canonicalIrBytes(legacyIr).toString("base64")); + assert.equal(loaded.revisions.executable, executable); + const journal = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created(legacyIr) })]; + assert.equal(foldRun(journal).graph, legacyIr); +}); + test("closed grammar rejects Stage 2 syntax and convergence bypass", async () => { const files = await reviewFiles(); for (const replacement of [ - '', '', - '', '', '', + '', '', + '', '', '', ]) { - const copied = { ...files, "review.loop": Buffer.from(files["review.loop"].toString().replace('', replacement)) }; + const copied = { ...files, "review.loop": Buffer.from(files["review.loop"].toString().replace('', replacement)) }; const result = compileLoopFiles(copied); assert.equal(result.ok, false, replacement); assert.ok(result.diagnostics.length > 0); } @@ -57,9 +87,32 @@ test("reviewer requirements close on the supervised Stage 1 boundary", async () } }); +test("singleton declarations and frozen agent invariants fail closed", async () => { + const files = await reviewFiles(), source = files["review.loop"].toString(); + for (const element of [ + source.match(/ /u)?.[0], + source.match(/ /u)?.[0], + ]) { + assert.ok(element); + const duplicated = compileLoopFiles({ + ...files, + "review.loop": Buffer.from(source.replace(element, `${element}\n${element}`)), + }); + assert.equal(duplicated.ok, false); + assert.ok(duplicated.diagnostics.some((item) => item.code === "E_CHILD_COUNT")); + } + const result = await compiled(); + const wrongTask = structuredClone(result.ir); + Object.assign(wrongTask.nodes.find((node) => node.id === "decompose"), { role: "reviewer", authority: "read" }); + assert.equal(validateClosedIr(wrongTask), false); + const wrongReviewer = structuredClone(result.ir); + wrongReviewer.nodes.find((node) => node.id === "review").independentFrom = "final-review"; + assert.equal(validateClosedIr(wrongReviewer), false); +}); + test("each semantic outcome has one closed, type-safe target", async () => { const files = await reviewFiles(); - const source = files["review.loop"].toString().replace('from="verify" on="pass" to="review"', 'from="verify" on="pass" to="implement"'); + const source = files["review.loop"].toString().replace('from="validate" on="pass" to="review"', 'from="validate" on="pass" to="implement"'); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_TARGET")); @@ -67,7 +120,7 @@ test("each semantic outcome has one closed, type-safe target", async () => { test("diagnostics are stable, retained, sorted, and capped", async () => { const files = await reviewFiles(); - const malformed = files["review.loop"].toString().replace('max-rounds="3"', 'max-rounds="0" evil="1"').replace('', ''); + const malformed = files["review.loop"].toString().replace('max-rounds="12"', 'max-rounds="0" evil="1"').replace('', ''); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(malformed) }); assert.equal(result.ok, false); const lines = renderDiagnostics(result.diagnostics).trim().split("\n"); @@ -86,12 +139,12 @@ test("package lexical limits fail closed before grammar compilation", async () = test("instruction extraction is exact and treats fenced headings as prose", async () => { const files = await reviewFiles(); - const markdown = "## implement\ntext\n```md\n## review\n```\n## review\nreview text\n"; + const markdown = "## start\nscope\n```md\n## review\n```\n## review\nreview text\n## decompose\ndecompose\n## implement\nimplement\n## integrate\nintegrate\n## final-review\ninspect\n"; const good = compileLoopFiles({ ...files, "instructions.md": Buffer.from(markdown) }); assert.equal(good.ok, true, renderDiagnostics(good.diagnostics ?? [])); - const bad = compileLoopFiles({ ...files, "instructions.md": Buffer.from("## implement\ntext\n## implement\nagain\n") }); + const bad = compileLoopFiles({ ...files, "instructions.md": Buffer.from("## start\nscope\n## start\nagain\n") }); assert.equal(bad.ok, false); assert.ok(bad.diagnostics.some((item) => item.code === "E_INSTRUCTIONS_DUPLICATE")); - const tilde = "## implement\ntext\n~~~ language ` allowed\n## review\n~~~ \n## review\nreview text\n"; + const tilde = "## start\nscope\n~~~\n## review\nfenced text\n~~~\n## review\nreview text\n## decompose\nsplit\n## implement\nimplement\n## integrate\nintegrate\n## final-review\ninspect\n"; assert.equal(compileLoopFiles({ ...files, "instructions.md": Buffer.from(tilde) }).ok, true); }); @@ -129,30 +182,28 @@ test("frozen replay rejects every closed-IR union, cap, reference, and ordering ]) assert.throws(() => loadFrozenRecipe(mutate(change)), TypeError); }); -test("closed grammar rejects named later constructs and group/order violations", async () => { +test("closed grammar rejects named later constructs", async () => { const files = await reviewFiles(), source = files["review.loop"].toString(); for (const name of ["input", "condition", "source", "operator", "target", "map", "foreach", "join", "combine", "branch"]) { - const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace('', `<${name} id="later"/>`)) }); + const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace('', `<${name} id="later"/>`)) }); assert.equal(result.ok, false, name); assert.ok(result.diagnostics.some((item) => item.code === "E_ELEMENT_UNKNOWN")); } - const reordered = source.replace(/(\n) ()/, "$2\n $1"); - const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(reordered) }); - assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_CHILD_GROUP")); + const altered = Buffer.from(source.replace('', '')); + const result = compileLoopFiles({ ...files, "review.loop": altered }); + assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED")); }); test("every Stage 1 element required attribute and scalar union is closed", async () => { const files = await reviewFiles(), source = files["review.loop"].toString(); const required = [ - 'id="review"', 'version="0.1.0"', 'entry="implement"', 'max-rounds="3"', 'max-minutes="60"', 'max-agent-runs="6"', 'max-check-runs="3"', 'max-transitions="16"', 'max-output-bytes="262144"', - 'id="implement"', 'mode="task"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'capability="repo-verify"', - 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', - 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="implement"', 'on="complete"', 'to="verify"', + 'id="start"', 'version="0.1.0"', 'entry="start"', 'max-rounds="12"', 'max-minutes="60"', 'max-agent-runs="18"', 'max-check-runs="8"', 'max-transitions="40"', 'max-output-bytes="262144"', + 'id="implement"', 'mode="task"', 'execution="managed"', 'intelligence="standard"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'id="decompose"', 'id="integrate"', 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', 'id="final-review"', 'independent-from="implement"', 'id="validate"', 'id="final-validate"', 'capability="repo-verify"', 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="start"', 'on="complete"', 'to="decompose"', ]; for (const token of required) { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, "")) }); assert.equal(result.ok, false, token); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED"), token); } - for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { + for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['execution="managed"', 'execution="remote"'], ['intelligence="standard"', 'intelligence="provider-name"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, replacement)) }); assert.equal(result.ok, false, replacement); } @@ -160,7 +211,7 @@ test("every Stage 1 element required attribute and scalar union is closed", asyn test("diagnostic truncation and recovery keep the first 99 sorted findings", async () => { const files = await reviewFiles(), extras = Array.from({ length: 110 }, (_, index) => ` bad-${index}="x"`).join(""); - const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="3"', `max-rounds="0"${extras}`).replace('', ''); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="12"', `max-rounds="0"${extras}`).replace('', ''); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); assert.equal(result.diagnostics[0].code, "E_TOO_MANY_DIAGNOSTICS"); @@ -173,7 +224,7 @@ test("grammar diagnoses malformed attribute and complete semantic/system routing const duplicate = source.replace('id="review" version', 'id="review" id="again" version'); let result = compileLoopFiles({ ...files, "review.loop": Buffer.from(duplicate) }); assert.equal(result.ok, false); assert.deepEqual(result.diagnostics[0], { path: "review.loop", byteOffset: 18, code: "E_XML_DUPLICATE_ATTRIBUTE", message: "Duplicate attribute id" }); - const missing = source.replace('\n', ""); + const missing = source.replace('\n', ""); result = compileLoopFiles({ ...files, "review.loop": Buffer.from(missing) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_MISSING")); const system = source.replace('lost="needs-human"', 'lost="failed"'); @@ -182,14 +233,14 @@ test("grammar diagnoses malformed attribute and complete semantic/system routing const duplicateSystem = source.replace('error="failed"', 'error="failed" error="failed"'); result = compileLoopFiles({ ...files, "review.loop": Buffer.from(duplicateSystem) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_XML_DUPLICATE_ATTRIBUTE")); - const bypass = source.replace('from="review" on="approve" to="converged"', 'from="review" on="approve" to="completed"'); + const bypass = source.replace('from="final-review" on="approve" to="converged"', 'from="final-review" on="approve" to="completed"'); result = compileLoopFiles({ ...files, "review.loop": Buffer.from(bypass) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_EDGE_TARGET" || item.code === "E_CONVERGENCE_DOMINATION")); }); test("recoverable XML findings merge with semantic findings before global sorting", async () => { const files = await reviewFiles(); - const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0" version="still-bad"').replace('max-rounds="3"', 'max-rounds="0" unknown="x"'); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0" version="still-bad"').replace('max-rounds="12"', 'max-rounds="0" unknown="x"'); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(result.ok, false); assert.deepEqual(result.diagnostics.map((item) => item.code), ["E_SCALAR", "E_XML_DUPLICATE_ATTRIBUTE", "E_ATTRIBUTE_UNKNOWN", "E_SCALAR"]); @@ -200,11 +251,11 @@ test("literal fenced-section grammar handles both delimiters and false closers", const files = await reviewFiles(); for (const marker of ["`", "~"]) for (const indent of [0, 1, 2, 3]) { const fence = `${" ".repeat(indent)}${marker.repeat(3)} suffix ${marker}\n## review\n${" ".repeat(indent)}${marker.repeat(4)} \n`; - const markdown = `## implement\ntext\n${fence}## review\nreview text\n`; + const markdown = `## start\nscope\n${fence}## review\nreview text\n## decompose\nsplit\n## implement\nwork\n## integrate\ncombine\n## final-review\ninspect\n`; const result = compileLoopFiles({ ...files, "instructions.md": Buffer.from(markdown) }); assert.equal(result.ok, true, `${marker}/${indent}`); } - const falseCloser = "## implement\ntext\n``` suffix `\n## review\n``` not-close\n## review\nreview\n"; + const falseCloser = "## start\nscope\n``` suffix `\n## review\n``` not-close\n## review\nreview\n## decompose\nsplit\n## implement\nwork\n## integrate\ncombine\n## final-review\ninspect\n"; const result = compileLoopFiles({ ...files, "instructions.md": Buffer.from(falseCloser) }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_INSTRUCTIONS_FENCE")); }); @@ -228,7 +279,7 @@ test("frozen recipe replay rejects any noncanonical JSON encoding", async () => const spaced = Buffer.from(`${frozen.toString("utf8").trim()} \n`, "utf8"); assert.throws(() => loadFrozenRecipe(spaced), /Frozen recipe is not canonical/); - const scientific = Buffer.from(frozen.toString("utf8").replace("\"maxRounds\":3", "\"maxRounds\":1e1"), "utf8"); + const scientific = Buffer.from(frozen.toString("utf8").replace("\"maxRounds\":12", "\"maxRounds\":1.2e1"), "utf8"); assert.throws(() => loadFrozenRecipe(scientific), /Frozen recipe is not canonical/); }); @@ -258,18 +309,15 @@ test("compiler invariant mirror rejects duplicated reviewer instruction IDs", as test("compiler invariant mirror rejects non-task maker entry", async () => { const files = await reviewFiles(); - let source = files["review.loop"].toString().replace('entry="implement"', 'entry="verify"'); - source = source.replace('', ''); - source = source.replace(' ', ' '); - source = source.replace(' ', ' '); + const source = files["review.loop"].toString().replace('entry="start"', 'entry="validate"'); const reassigned = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(reassigned.ok, false); - assert.ok(reassigned.diagnostics.some((item) => item.code === "E_IR_INVARIANT")); + assert.ok(reassigned.diagnostics.some((item) => ["E_REACHABILITY", "E_ENTRY_KIND", "E_IR_INVARIANT"].includes(item.code))); }); test("diagnostic truncation keeps the first 99 sorted findings", async () => { const files = await reviewFiles(), extras = Array.from({ length: 110 }, (_, index) => ` bad-${index}="x"`).join(""); - const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="3"', `max-rounds="0"${extras}`).replace('', ''); + const source = files["review.loop"].toString().replace('version="0.1.0"', 'version="0"').replace('max-rounds="12"', `max-rounds="0"${extras}`).replace('', ''); const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source) }); assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); assert.deepEqual(result.diagnostics[0], { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }); diff --git a/src/loops/dsl/frozen.mjs b/src/loops/dsl/frozen.mjs index 8d6ab0aa..71b95e6d 100644 --- a/src/loops/dsl/frozen.mjs +++ b/src/loops/dsl/frozen.mjs @@ -1,12 +1,13 @@ import { canonicalIrBytes } from "./canonical.mjs"; import { prefixed, rawSha256 } from "./hash.mjs"; -import { validateClosedIr } from "./ir-validate.mjs"; +import { validateReplayIr } from "./ir-validate.mjs"; const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const digest = /^[a-f0-9]{64}$/; const sections = ["schema", "compiler", "revisions", "source", "package", "ir", "instructions"]; const revisionKeys = ["source", "package", "executable"]; const packageSizes = { "review.loop": [1, 65536], "instructions.md": [1, 262144], "example/item.md": [0, 65536] }; +const loopPath = /^[a-z0-9]+(?:-[a-z0-9]+)*\.loop$/u; function sortByPath(entries) { return [...entries].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); @@ -46,9 +47,11 @@ function freeze(value) { if (!value || typeof value !== "object") return value; /** Serializes the only runtime-facing compiler product: immutable IR and source/package bytes. */ export function freezeRecipe(compiled) { if (!compiled?.ok || !compiled.ir || !Buffer.isBuffer(compiled.irBytes) || !compiled.packageFiles) throw new TypeError("A successful compile result is required"); + const sourcePath = Object.keys(compiled.packageFiles).find((path) => loopPath.test(path)); + if (!sourcePath) throw new TypeError("A successful compile result is required"); const packageFiles = Object.entries(compiled.packageFiles).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right))).map(([path, bytes]) => ({ path, bytes: Buffer.from(bytes).toString("base64") })); const instructions = compiled.instructions.map((section) => ({ id: section.id, digest: section.digest, bytes: Buffer.from(section.bytes).toString("base64") })); - return Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-frozen@1", compiler: compiled.ir.compiler, revisions: compiled.revisions, source: Buffer.from(compiled.packageFiles["review.loop"]).toString("base64"), package: packageFiles, ir: JSON.parse(compiled.irBytes), instructions })}\n`, "utf8"); + return Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-frozen@1", compiler: compiled.ir.compiler, revisions: compiled.revisions, source: Buffer.from(compiled.packageFiles[sourcePath]).toString("base64"), package: packageFiles, ir: JSON.parse(compiled.irBytes), instructions })}\n`, "utf8"); } /** Runtime/replay boundary: verify frozen bytes and never recompile installed source. */ @@ -61,11 +64,12 @@ export function loadFrozenRecipe(bytes) { } catch { throw new TypeError("Frozen recipe is not canonical"); } - if (!exact(value, sections) || value.schema !== "burnlist-loop-frozen@1" || value.compiler !== "burnlist-loop-compiler@1" || !exact(value.revisions, revisionKeys) || !revision(value.revisions.source, "ls1") || !revision(value.revisions.package, "lp1") || !revision(value.revisions.executable, "er1") || !validateClosedIr(value.ir) || value.ir.compiler !== value.compiler) throw new TypeError("Frozen recipe has an invalid envelope"); + if (!exact(value, sections) || value.schema !== "burnlist-loop-frozen@1" || value.compiler !== "burnlist-loop-compiler@1" || !exact(value.revisions, revisionKeys) || !revision(value.revisions.source, "ls1") || !revision(value.revisions.package, "lp1") || !revision(value.revisions.executable, "er1") || !validateReplayIr(value.ir) || value.ir.compiler !== value.compiler) throw new TypeError("Frozen recipe has an invalid envelope"); const source = base64(value.source); if (!Array.isArray(value.package) || value.package.length < 2 || value.package.length > 3 || !Array.isArray(value.instructions)) throw new TypeError("Frozen recipe has an invalid package"); - const packageFiles = value.package.map((item) => { if (!exact(item, ["path", "bytes"]) || !Object.hasOwn(packageSizes, item.path)) throw new TypeError("Frozen recipe has an invalid package"); const content = base64(item.bytes), [minimum, maximum] = packageSizes[item.path]; if (content.length < minimum || content.length > maximum) throw new TypeError("Frozen recipe has an invalid package"); return { path: item.path, bytes: content }; }); - if (new Set(packageFiles.map((item) => item.path)).size !== packageFiles.length || packageFiles.reduce((total, item) => total + item.bytes.length, 0) > 393216 || !packageFiles.some((item) => item.path === "review.loop" && item.bytes.equals(source)) || !packageFiles.some((item) => item.path === "instructions.md")) throw new TypeError("Frozen recipe has an invalid package"); + const packageFiles = value.package.map((item) => { const limits = loopPath.test(item?.path) ? [1, 65536] : packageSizes[item?.path]; if (!exact(item, ["path", "bytes"]) || !limits) throw new TypeError("Frozen recipe has an invalid package"); const content = base64(item.bytes), [minimum, maximum] = limits; if (content.length < minimum || content.length > maximum) throw new TypeError("Frozen recipe has an invalid package"); return { path: item.path, bytes: content }; }); + const sources = packageFiles.filter((item) => loopPath.test(item.path)); + if (new Set(packageFiles.map((item) => item.path)).size !== packageFiles.length || sources.length !== 1 || packageFiles.reduce((total, item) => total + item.bytes.length, 0) > 393216 || !sources[0].bytes.equals(source) || !packageFiles.some((item) => item.path === "instructions.md")) throw new TypeError("Frozen recipe has an invalid package"); const sortedPackage = [...packageFiles].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); const packageDigest = prefixed("lp1-sha256:", "package-v1", sortedPackage.flatMap((item) => [Buffer.from(item.path), item.bytes])); if (prefixed("ls1-sha256:", "source-v1", [source]) !== value.revisions.source || packageDigest !== value.revisions.package) throw new TypeError("Frozen recipe provenance revision does not match bytes"); diff --git a/src/loops/dsl/grammar.mjs b/src/loops/dsl/grammar.mjs index 1fea377e..c10ed057 100644 --- a/src/loops/dsl/grammar.mjs +++ b/src/loops/dsl/grammar.mjs @@ -5,14 +5,13 @@ const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const route = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*$/; const semver = /^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$/; const positive = /^[1-9][0-9]*$/; -const order = ["budget", "agent", "check", "agent", "gate", "terminal", "terminal", "terminal", "terminal", "terminal", "failure-policy", "edge", "edge", "edge", "edge", "edge", "edge", "edge", "edge"]; const attrs = { loop: ["id", "version", "entry"], budget: ["max-rounds", "max-minutes", "max-agent-runs", "max-check-runs", "max-transitions", "max-output-bytes"], - agent: ["id", "mode", "role", "route", "authority", "instructions", "independent-from", "requires"], check: ["id", "capability"], gate: ["id", "kind", "requires"], + agent: ["id", "mode", "execution", "intelligence", "role", "route", "authority", "instructions", "independent-from", "requires"], check: ["id", "capability"], gate: ["id", "kind", "requires"], terminal: ["id", "state"], "failure-policy": ["error", "timeout", "cancelled", "lost", "exhausted"], edge: ["from", "on", "to", "max-visits"], }; const required = { - loop: attrs.loop, budget: attrs.budget, agent: ["id", "mode", "role", "route", "authority", "instructions"], check: ["id", "capability"], gate: attrs.gate, terminal: attrs.terminal, + loop: attrs.loop, budget: attrs.budget, agent: ["id", "mode", "execution", "intelligence", "role", "route", "authority", "instructions"], check: ["id", "capability"], gate: attrs.gate, terminal: attrs.terminal, "failure-policy": attrs["failure-policy"], edge: ["from", "on", "to"], }; const outcomes = { agent: (node) => node.mode === "task" ? ["complete"] : ["approve", "reject", "escalate"], check: () => ["pass", "fail"], gate: () => ["pass", "fail"], terminal: () => [] }; @@ -46,9 +45,14 @@ export function validateLoop(ast) { exactAttrs(d, ast); if (ast.selfClosing) add(d, ast, "E_ROOT_FORM", "Root must not be self-closing"); value(d, ast, "id", (v) => slug.test(v), "a lowercase slug"); value(d, ast, "version", (v) => semver.test(v), "a Stage 1 SemVer"); value(d, ast, "entry", (v) => slug.test(v), "a lowercase slug"); - ast.children.forEach((node, index) => { exactAttrs(d, node); if (!node.selfClosing) add(d, node, "E_CHILD_FORM", `<${node.name}> must be self-closing`); if (node.children.length) add(d, node, "E_CHILDREN", `<${node.name}> may not have children`); if (node.name !== order[index]) add(d, node, "E_CHILD_ORDER", "Children must use the closed Stage 1 group order"); if (index === 1 && node.attrs.mode !== "task") add(d, node, "E_CHILD_GROUP", "Maker agent group must precede check"); if (index === 3 && node.attrs.mode !== "review") add(d, node, "E_CHILD_GROUP", "Reviewer agent group must follow check"); }); - if (ast.children.length !== order.length) add(d, ast, "E_CHILD_COUNT", "Loop must contain the exact closed Stage 1 child set"); + ast.children.forEach((node) => { exactAttrs(d, node); if (!node.selfClosing) add(d, node, "E_CHILD_FORM", `<${node.name}> must be self-closing`); if (node.children.length) add(d, node, "E_CHILDREN", `<${node.name}> may not have children`); }); + const kindTotals = new Map(); + for (const node of ast.children) kindTotals.set(node.name, (kindTotals.get(node.name) ?? 0) + 1); + if (kindTotals.get("budget") !== 1) add(d, ast, "E_CHILD_COUNT", "Loop must include exactly one budget declaration"); + if (kindTotals.get("failure-policy") !== 1) add(d, ast, "E_CHILD_COUNT", "Loop must include exactly one failure-policy declaration"); + if (!kindTotals.get("edge")) add(d, ast, "E_CHILD_COUNT", "Loop must include at least one edge"); const ids = new Map(), nodes = [], edges = [], policy = ast.children.find((node) => node.name === "failure-policy"); + const budget = ast.children.find((node) => node.name === "budget"); for (const node of ast.children) { if (["agent", "check", "gate", "terminal"].includes(node.name)) { value(d, node, "id", (v) => slug.test(v), "a lowercase slug"); @@ -56,13 +60,16 @@ export function validateLoop(ast) { } if (node.name === "budget") for (const [key, [min, max]] of Object.entries(limits)) value(d, node, key, (v) => positive.test(v) && +v >= min && +v <= max, `an integer from ${min} through ${max}`); if (node.name === "agent") { - value(d, node, "mode", (v) => v === "task" || v === "review", "task or review"); value(d, node, "role", (v) => v === "maker" || v === "reviewer", "maker or reviewer"); + value(d, node, "mode", (v) => v === "task" || v === "review", "task or review"); + value(d, node, "execution", (v) => v === "managed" || v === "host", "managed or host"); + value(d, node, "intelligence", (v) => ["fast", "standard", "strong", "critical"].includes(v), "fast, standard, strong, or critical"); + value(d, node, "role", (v) => v === "maker" || v === "reviewer", "maker or reviewer"); value(d, node, "route", (v) => route.test(v), "a Route"); value(d, node, "authority", (v) => v === "read" || v === "write", "read or write"); value(d, node, "instructions", (v) => slug.test(v), "a lowercase slug"); const task = node.attrs.mode === "task"; const expected = task ? ["maker", "write"] : ["reviewer", "read"]; if ((node.attrs.role && node.attrs.authority) && (node.attrs.role !== expected[0] || node.attrs.authority !== expected[1])) add(d, node, "E_AGENT_COMBINATION", `${node.attrs.mode} agent must be ${expected[0]}/${expected[1]}`); if (task && ("independent-from" in node.attrs || "requires" in node.attrs)) add(d, node, "E_AGENT_ATTRIBUTES", "Task agent may not have review-only attributes"); if (!task) { for (const key of ["independent-from", "requires"]) if (!(key in node.attrs)) add(d, node, "E_ATTRIBUTE_REQUIRED", ` requires attribute ${key}`); if (node.attrs.requires !== "fresh-session:enforced,filesystem-write-deny:supervised") add(d, node, "E_REVIEW_REQUIREMENTS", "Review requires must be fresh-session:enforced,filesystem-write-deny:supervised"); } - nodes.push({ kind: "agent", id: node.attrs.id, mode: node.attrs.mode, role: node.attrs.role, route: node.attrs.route, authority: node.attrs.authority, instructions: node.attrs.instructions, independentFrom: node.attrs["independent-from"] ?? null, requires: node.attrs.requires ? node.attrs.requires.split(",") : [] }); + nodes.push({ kind: "agent", id: node.attrs.id, mode: node.attrs.mode, execution: node.attrs.execution, intelligence: node.attrs.intelligence, role: node.attrs.role, route: node.attrs.route, authority: node.attrs.authority, instructions: node.attrs.instructions, independentFrom: node.attrs["independent-from"] ?? null, requires: node.attrs.requires ? node.attrs.requires.split(",") : [] }); } if (node.name === "check") { value(d, node, "capability", (v) => slug.test(v), "a lowercase slug"); nodes.push({ kind: "check", id: node.attrs.id, capability: node.attrs.capability }); } if (node.name === "gate") { if (node.attrs.kind !== "convergence") add(d, node, "E_GATE_KIND", "Stage 1 gate kind must be convergence"); nodes.push({ kind: "gate", id: node.attrs.id, gateKind: node.attrs.kind, requires: node.attrs.requires?.split(",") ?? [] }); } @@ -70,12 +77,19 @@ export function validateLoop(ast) { if (node.name === "edge") { if ("max-visits" in node.attrs) value(d, node, "max-visits", (v) => positive.test(v) && +v <= 100, "an integer from 1 through 100"); edges.push({ from: node.attrs.from, on: node.attrs.on, to: node.attrs.to, maxVisits: node.attrs["max-visits"] ? +node.attrs["max-visits"] : null, offset: node.byteOffset }); } } const agents = nodes.filter((node) => node.kind === "agent"), checks = nodes.filter((node) => node.kind === "check"), gates = nodes.filter((node) => node.kind === "gate"), terminals = nodes.filter((node) => node.kind === "terminal"); - if (agents.length !== 2 || agents.filter((node) => node.mode === "task").length !== 1 || agents.filter((node) => node.mode === "review").length !== 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires one task agent and one review agent"); - if (checks.length !== 1 || gates.length !== 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires exactly one check and one convergence gate"); + const taskAgents = agents.filter((node) => node.mode === "task"), reviewAgents = agents.filter((node) => node.mode === "review"); + if (taskAgents.length < 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires at least one task agent"); + if (reviewAgents.length < 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires at least one review agent"); + if (checks.length < 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires at least one check node"); + if (gates.length !== 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires exactly one convergence gate"); for (const state of terminalStates) if (terminals.filter((node) => node.state === state).length !== 1) add(d, ast, "E_TERMINAL_CARDINALITY", `Stage 1 requires exactly one ${state} terminal`); - const reviewer = agents.find((node) => node.mode === "review"), maker = agents.find((node) => node.mode === "task"), check = checks[0], gate = gates[0]; - if (reviewer && reviewer.independentFrom !== maker?.id) add(d, ids.get(reviewer.id), "E_REVIEW_INDEPENDENCE", "Reviewer independent-from must name the task agent"); - if (gate && `${gate.requires.join(",")}` !== `${check?.id ?? ""},${reviewer?.id ?? ""}`) add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must name check then reviewer"); + for (const reviewer of reviewAgents) if (!taskAgents.some((task) => task.id === reviewer.independentFrom)) add(d, ids.get(reviewer.id), "E_REVIEW_INDEPENDENCE", "Reviewer independent-from must name an existing task agent"); + const gate = gates[0], gateRequires = new Set(gate?.requires ?? []); + if (gate && (gateRequires.size !== gate.requires.length + || !reviewAgents.some((node) => gateRequires.has(node.id)) + || [...gateRequires].some((id) => !checks.some((node) => node.id === id) && !reviewAgents.some((node) => node.id === id)))) { + add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must uniquely reference at least one review node and only check/review nodes"); + } if (policy) for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) { const target = ids.get(policy.attrs[outcome]); if (!target || target.attrs.state !== state) add(d, policy, "E_FAILURE_POLICY", `${outcome} must target the ${state} terminal`); } for (const edge of edges) { const from = ids.get(edge.from), to = ids.get(edge.to); @@ -84,12 +98,12 @@ export function validateLoop(ast) { const source = nodes.find((node) => node.id === edge.from); if (!outcomes[source?.kind]?.(source).includes(edge.on)) d.add("review.loop", edge.offset, "E_EDGE_OUTCOME", `Outcome ${edge.on} is not emitted by ${edge.from}`); const target = nodes.find((node) => node.id === edge.to); - const allowed = (source?.kind === "agent" && source.mode === "task" && edge.on === "complete" && target?.kind === "check") || - (source?.kind === "check" && edge.on === "pass" && target?.kind === "agent" && target.mode === "review") || - (source?.kind === "check" && edge.on === "fail" && target?.kind === "agent" && target.mode === "task") || - (source?.kind === "agent" && source.mode === "review" && edge.on === "reject" && target?.kind === "agent" && target.mode === "task") || - (source?.kind === "agent" && source.mode === "review" && edge.on === "approve" && target?.kind === "gate") || + const allowed = (source?.kind === "agent" && source.mode === "task" && edge.on === "complete" && target?.kind !== "terminal") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "approve" && target?.kind !== "terminal") || + (source?.kind === "agent" && source.mode === "review" && edge.on === "reject" && target?.kind !== "terminal") || (source?.kind === "agent" && source.mode === "review" && edge.on === "escalate" && target?.kind === "terminal" && target.state === "needs-human") || + (source?.kind === "check" && edge.on === "pass" && target?.kind === "agent" && target?.mode === "review") || + (source?.kind === "check" && edge.on === "fail" && target?.kind === "agent" && target?.mode === "task") || (source?.kind === "gate" && edge.on === "pass" && target?.kind === "terminal" && target.state === "converged") || (source?.kind === "gate" && edge.on === "fail" && target?.kind === "terminal" && target.state === "needs-human"); if (!allowed) d.add("review.loop", edge.offset, "E_EDGE_TARGET", `Target ${edge.to} is not allowed for ${edge.from}/${edge.on}`); @@ -102,12 +116,13 @@ export function validateLoop(ast) { // System outcomes are runner-owned but are still graph routes for reachability. const systemEdges = policy ? nodes.filter((node) => node.kind !== "terminal").flatMap((node) => Object.entries(policy.attrs).map(([on, to]) => ({ from: node.id, on, to }))) : []; const reachable = reachesTerminal(nodes, [...edges, ...systemEdges], ast.attrs.entry); if (!ids.has(ast.attrs.entry)) add(d, ast, "E_ENTRY", "Entry must name a declared node"); + else if (!taskAgents.some((node) => node.id === ast.attrs.entry)) add(d, ast, "E_ENTRY_KIND", "Entry must name a task agent"); else for (const node of nodes) if (!reachable.has(node.id)) add(d, ids.get(node.id), "E_REACHABILITY", `Node ${node.id} is not reachable from entry`); const back = ids.has(ast.attrs.entry) ? backEdges(nodes, edges, ast.attrs.entry) : new Set(); for (const edge of edges) { const needs = back.has(edge); if (needs !== (edge.maxVisits !== null)) d.add("review.loop", edge.offset, "E_EDGE_VISITS", needs ? "DFS back edges require max-visits" : "max-visits is allowed only on DFS back edges"); } if (d.all.length) return { diagnostics: d.list, allDiagnostics: d.all }; const ir = normalizeIr({ schema: "burnlist-loop-ir@1", compiler: "burnlist-loop-compiler@1", id: ast.attrs.id, declaredVersion: ast.attrs.version, entry: ast.attrs.entry, - budget: { maxRounds: +ast.children[0].attrs["max-rounds"], maxMinutes: +ast.children[0].attrs["max-minutes"], maxAgentRuns: +ast.children[0].attrs["max-agent-runs"], maxCheckRuns: +ast.children[0].attrs["max-check-runs"], maxTransitions: +ast.children[0].attrs["max-transitions"], maxOutputBytes: +ast.children[0].attrs["max-output-bytes"] }, nodes, failurePolicy: { error: policy.attrs.error, timeout: policy.attrs.timeout, cancelled: policy.attrs.cancelled, lost: policy.attrs.lost, exhausted: policy.attrs.exhausted }, edges: edges.map(({ offset, ...edge }) => edge), instructions: [] }, (node) => outcomes[node.kind](node)); + budget: { maxRounds: +budget.attrs["max-rounds"], maxMinutes: +budget.attrs["max-minutes"], maxAgentRuns: +budget.attrs["max-agent-runs"], maxCheckRuns: +budget.attrs["max-check-runs"], maxTransitions: +budget.attrs["max-transitions"], maxOutputBytes: +budget.attrs["max-output-bytes"] }, nodes, failurePolicy: { error: policy.attrs.error, timeout: policy.attrs.timeout, cancelled: policy.attrs.cancelled, lost: policy.attrs.lost, exhausted: policy.attrs.exhausted }, edges: edges.map(({ offset, ...edge }) => edge), instructions: [] }, (node) => outcomes[node.kind](node)); return { ir, instructionIds: agents.map((node) => node.instructions), diagnostics: [], allDiagnostics: [] }; } diff --git a/src/loops/dsl/ir-validate.mjs b/src/loops/dsl/ir-validate.mjs index 6bd01d4c..22ea18fa 100644 --- a/src/loops/dsl/ir-validate.mjs +++ b/src/loops/dsl/ir-validate.mjs @@ -8,26 +8,59 @@ const states = ["converged", "needs-human", "failed", "stopped", "budget-exhaust const top = ["schema", "compiler", "id", "declaredVersion", "entry", "budget", "nodes", "failurePolicy", "edges", "instructions"]; const budget = ["maxRounds", "maxMinutes", "maxAgentRuns", "maxCheckRuns", "maxTransitions", "maxOutputBytes"]; const policy = ["error", "timeout", "cancelled", "lost", "exhausted"]; -const nodeKeys = { agent: ["kind", "id", "mode", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; +const nodeKeys = { agent: ["kind", "id", "mode", "execution", "intelligence", "role", "route", "authority", "instructions", "independentFrom", "requires"], check: ["kind", "id", "capability"], gate: ["kind", "id", "gateKind", "requires"], terminal: ["kind", "id", "state"] }; const limits = { maxRounds: [1, 100], maxMinutes: [1, 1440], maxAgentRuns: [1, 100], maxCheckRuns: [1, 100], maxTransitions: [1, 1000], maxOutputBytes: [1024, 1048576] }; +const reviewRequirements = ["fresh-session:enforced", "filesystem-write-deny:supervised"]; function exact(value, keys) { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } function boundedSlug(value) { return typeof value === "string" && value.length <= 65536 && slug.test(value); } function integer(value, range = [0, Number.MAX_SAFE_INTEGER]) { return Number.isSafeInteger(value) && value >= range[0] && value <= range[1]; } function same(left, right) { return JSON.stringify(left) === JSON.stringify(right); } + function validNode(node) { if (!exact(node, nodeKeys[node?.kind] ?? []) || !boundedSlug(node.id)) return false; - if (node.kind === "agent") return ["task", "review"].includes(node.mode) && ["maker", "reviewer"].includes(node.role) && route.test(node.route) && ["read", "write"].includes(node.authority) && boundedSlug(node.instructions) && (node.independentFrom === null || boundedSlug(node.independentFrom)) && Array.isArray(node.requires) && node.requires.every((item) => typeof item === "string" && item.length <= 128); + if (node.kind === "agent") { + if (!["task", "review"].includes(node.mode) || !["managed", "host"].includes(node.execution) + || !["fast", "standard", "strong", "critical"].includes(node.intelligence) + || !route.test(node.route) || !boundedSlug(node.instructions) + || !Array.isArray(node.requires) || node.requires.some((item) => typeof item !== "string" || item.length > 128)) return false; + return node.mode === "task" + ? node.role === "maker" && node.authority === "write" && node.independentFrom === null && node.requires.length === 0 + : node.role === "reviewer" && node.authority === "read" && boundedSlug(node.independentFrom) && same(node.requires, reviewRequirements); + } if (node.kind === "check") return boundedSlug(node.capability); if (node.kind === "gate") return node.gateKind === "convergence" && Array.isArray(node.requires) && node.requires.every(boundedSlug); return states.includes(node.state); } + +function backEdges(nodes, edges, entry) { + const byFrom = new Map(); + for (const edge of edges) { + (byFrom.get(edge.from) ?? byFrom.set(edge.from, []).get(edge.from)).push(edge.to); + } + const color = new Map(); + const result = new Set(); + const visit = (id) => { + color.set(id, 1); + for (const to of byFrom.get(id) ?? []) { + if (color.get(to) === 1) { + result.add(`${id}\0${to}`); + } else if (!color.get(to)) { + visit(to); + } + } + color.set(id, 2); + }; + if (nodes.some((node) => node.id === entry)) visit(entry); + return result; +} + function targetAllowed(source, outcome, target) { - return (source.kind === "agent" && source.mode === "task" && outcome === "complete" && target.kind === "check") || + return (source.kind === "agent" && source.mode === "task" && outcome === "complete" && target.kind !== "terminal") || (source.kind === "check" && outcome === "pass" && target.kind === "agent" && target.mode === "review") || (source.kind === "check" && outcome === "fail" && target.kind === "agent" && target.mode === "task") || - (source.kind === "agent" && source.mode === "review" && outcome === "reject" && target.kind === "agent" && target.mode === "task") || - (source.kind === "agent" && source.mode === "review" && outcome === "approve" && target.kind === "gate") || + (source.kind === "agent" && source.mode === "review" && outcome === "reject" && target.kind !== "terminal") || + (source.kind === "agent" && source.mode === "review" && outcome === "approve" && target.kind !== "terminal") || (source.kind === "agent" && source.mode === "review" && outcome === "escalate" && target.kind === "terminal" && target.state === "needs-human") || (source.kind === "gate" && outcome === "pass" && target.kind === "terminal" && target.state === "converged") || (source.kind === "gate" && outcome === "fail" && target.kind === "terminal" && target.state === "needs-human"); @@ -35,24 +68,57 @@ function targetAllowed(source, outcome, target) { /** Rejects every noncanonical or unsupported symbolic IR before frozen replay. */ export function validateClosedIr(ir) { - if (!exact(ir, top) || ir.schema !== "burnlist-loop-ir@1" || ir.compiler !== "burnlist-loop-compiler@1" || !boundedSlug(ir.id) || !semver.test(ir.declaredVersion) || !boundedSlug(ir.entry) || !exact(ir.budget, budget) || !Object.entries(limits).every(([key, range]) => integer(ir.budget[key], range)) || !exact(ir.failurePolicy, policy) || !Object.values(ir.failurePolicy).every(boundedSlug) || !Array.isArray(ir.nodes) || ir.nodes.length > 64 || !ir.nodes.every(validNode) || !Array.isArray(ir.edges) || ir.edges.length > 512 || !Array.isArray(ir.instructions) || ir.instructions.length > 2) return false; + if (!exact(ir, top) || ir.schema !== "burnlist-loop-ir@1" || ir.compiler !== "burnlist-loop-compiler@1" || !boundedSlug(ir.id) || !semver.test(ir.declaredVersion) || !boundedSlug(ir.entry) || !exact(ir.budget, budget) || !Object.entries(limits).every(([key, range]) => integer(ir.budget[key], range)) || !exact(ir.failurePolicy, policy) || !Object.values(ir.failurePolicy).every(boundedSlug) || !Array.isArray(ir.nodes) || ir.nodes.length > 64 || !ir.nodes.every(validNode) || !Array.isArray(ir.edges) || ir.edges.length > 512 || !Array.isArray(ir.instructions) || ir.instructions.length > 64) return false; const ids = new Map(ir.nodes.map((node) => [node.id, node])); if (ids.size !== ir.nodes.length || !ids.has(ir.entry)) return false; - const agents = ir.nodes.filter((node) => node.kind === "agent"), makers = agents.filter((node) => node.mode === "task" && node.role === "maker" && node.authority === "write"), reviewers = agents.filter((node) => node.mode === "review" && node.role === "reviewer" && node.authority === "read"), checks = ir.nodes.filter((node) => node.kind === "check"), gates = ir.nodes.filter((node) => node.kind === "gate"), terminals = ir.nodes.filter((node) => node.kind === "terminal"), agentInstructionIds = new Set(agents.map((agent) => agent.instructions)); - if (agents.length !== 2 || makers.length !== 1 || reviewers.length !== 1 || checks.length !== 1 || gates.length !== 1 || ir.entry !== makers[0].id || states.some((state) => terminals.filter((node) => node.state === state).length !== 1) || reviewers[0].independentFrom !== makers[0].id || !same(reviewers[0].requires, ["fresh-session:enforced", "filesystem-write-deny:supervised"]) || makers[0].independentFrom !== null || makers[0].requires.length || !same(gates[0].requires, [checks[0].id, reviewers[0].id])) return false; - if (agentInstructionIds.size !== 2) return false; - if (!ir.instructions.every((section) => exact(section, ["id", "digest", "byteLength"]) && boundedSlug(section.id) && /^sha256:[a-f0-9]{64}$/.test(section.digest) && integer(section.byteLength, [1, 65536])) || new Set(ir.instructions.map((section) => section.id)).size !== ir.instructions.length || !same(ir.instructions.map((section) => section.id).sort(), [makers[0].instructions, reviewers[0].instructions].sort())) return false; + const agents = ir.nodes.filter((node) => node.kind === "agent"); + const makers = agents.filter((node) => node.mode === "task" && node.role === "maker" && node.authority === "write"); + const reviewers = agents.filter((node) => node.mode === "review" && node.role === "reviewer" && node.authority === "read"); + const checks = ir.nodes.filter((node) => node.kind === "check"); + const gates = ir.nodes.filter((node) => node.kind === "gate"); + const terminals = ir.nodes.filter((node) => node.kind === "terminal"); + const agentInstructionIds = new Set(agents.map((agent) => agent.instructions)); + + if (makers.length < 1 || reviewers.length < 1 || checks.length < 1 || gates.length !== 1 || states.some((state) => terminals.filter((node) => node.state === state).length !== 1)) return false; + if (ids.get(ir.entry)?.kind !== "agent" || ids.get(ir.entry)?.mode !== "task") return false; + if (!reviewers.every((reviewer) => ids.get(reviewer.independentFrom)?.kind === "agent" && ids.get(reviewer.independentFrom)?.mode === "task")) return false; + const gateRequires = new Set(gates[0].requires); + if (gateRequires.size !== gates[0].requires.length + || !gates[0].requires.some((id) => ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review") + || gates[0].requires.some((id) => ids.get(id)?.kind !== "check" && !(ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review"))) return false; + if (agentInstructionIds.size !== agents.length) return false; + + if (!ir.instructions.every((section) => exact(section, ["id", "digest", "byteLength"]) && boundedSlug(section.id) && /^sha256:[a-f0-9]{64}$/.test(section.digest) && integer(section.byteLength, [1, 65536])) || new Set(ir.instructions.map((section) => section.id)).size !== ir.instructions.length || !same(ir.instructions.map((section) => section.id).sort(), [...agentInstructionIds].sort())) return false; + for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) if (ids.get(ir.failurePolicy[outcome])?.state !== state) return false; const pairs = new Set(); for (const edge of ir.edges) { if (!exact(edge, ["from", "on", "to", "maxVisits"]) || !boundedSlug(edge.from) || !boundedSlug(edge.to) || typeof edge.on !== "string" || edge.on.length > 64 || (edge.maxVisits !== null && !integer(edge.maxVisits, [1, 100]))) return false; - const source = ids.get(edge.from), target = ids.get(edge.to), key = `${edge.from}\0${edge.on}`; - if (!source || !target || pairs.has(key) || !outcomesFor(source).includes(edge.on) || !targetAllowed(source, edge.on, target)) return false; - const back = (source.kind === "check" && edge.on === "fail") || (source.kind === "agent" && source.mode === "review" && edge.on === "reject"); + const source = ids.get(edge.from), target = ids.get(edge.to), outcomeKey = `${edge.from}\0${edge.on}`; + if (!source || !target || pairs.has(outcomeKey) || !outcomesFor(source).includes(edge.on) || !targetAllowed(source, edge.on, target)) return false; + const back = backEdges(ir.nodes, ir.edges, ir.entry).has(`${edge.from}\0${edge.to}`); if ((edge.maxVisits !== null) !== back) return false; - pairs.add(key); + pairs.add(outcomeKey); } - if (ir.edges.length !== 8 || ir.nodes.filter((node) => node.kind !== "terminal").some((node) => outcomesFor(node).some((outcome) => !pairs.has(`${node.id}\0${outcome}`)))) return false; + if (ir.nodes.filter((node) => node.kind !== "terminal").some((node) => outcomesFor(node).some((outcome) => !pairs.has(`${node.id}\0${outcome}`))) || ir.nodes.length < 2) return false; const normalized = normalizeIr(ir, outcomesFor); return same(ir.nodes, normalized.nodes) && same(ir.edges, normalized.edges) && same(ir.instructions, normalized.instructions); } + +/** + * Frozen Runs are immutable evidence. Pre-H6 recipes did not carry execution + * intent, so replay admits that closed historical shape without rewriting its + * bytes or making it valid compiler output. + */ +export function validateReplayIr(ir) { + if (validateClosedIr(ir)) return true; + if (!ir || !Array.isArray(ir.nodes) + || !ir.nodes.filter((node) => node?.kind === "agent").every((node) => !Object.hasOwn(node, "execution") && !Object.hasOwn(node, "intelligence"))) return false; + const upgraded = { + ...ir, + nodes: ir.nodes.map((node) => node?.kind === "agent" + ? { ...node, execution: "managed", intelligence: node.mode === "review" ? "strong" : "standard" } + : node), + }; + return validateClosedIr(upgraded); +} diff --git a/src/loops/dsl/loop-xml.mjs b/src/loops/dsl/loop-xml.mjs index e959c26e..55d95df1 100644 --- a/src/loops/dsl/loop-xml.mjs +++ b/src/loops/dsl/loop-xml.mjs @@ -66,7 +66,7 @@ export function parseLoopXml(input, { path = "review.loop" } = {}) { } const node = { name, attrs, children: [], byteOffset: offsetOf(source, begin), selfClosing }; elements++; - if (elements > 32) add(begin, "E_XML_LIMIT", "XML element limit is 32"); + if (elements > 64) add(begin, "E_XML_LIMIT", "XML element limit is 64"); if (stack.length >= 2) add(begin, "E_XML_DEPTH", "XML depth limit is 2"); if (stack.length) stack.at(-1).children.push(node); else if (root) add(begin, "E_XML_ROOT", "Only one root element is allowed"); diff --git a/src/loops/dsl/package-read.mjs b/src/loops/dsl/package-read.mjs index 8f43990c..7d269fa5 100644 --- a/src/loops/dsl/package-read.mjs +++ b/src/loops/dsl/package-read.mjs @@ -282,7 +282,10 @@ async function revalidateLeafBoundary(snapshot, output) { } } -export async function readPackageDirectory(directory, { beforeLeafRead, afterLeafOpenForTest } = {}) { +export async function readPackageDirectory(directory, { beforeLeafRead, afterLeafOpenForTest, loopFile = "review.loop" } = {}) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\.loop$/u.test(loopFile)) throw new TypeError("Invalid Loop package source name"); + const knownRoot = new Set([loopFile, "instructions.md", "example"]); + const fileLimits = { [loopFile]: 65536, "instructions.md": 262144, "example/item.md": 65536 }; const diagnostics = []; const files = {}; const root = await snapshotDirectory("", directory, diagnostics); @@ -294,7 +297,7 @@ export async function readPackageDirectory(directory, { beforeLeafRead, afterLea for (const name of root.entries) { const full = join(directory, name); - if (!KNOWN_ROOT.has(name)) { + if (!knownRoot.has(name)) { addDiagnostic(diagnostics, name, "E_PACKAGE_PATH", "Unknown package file"); continue; } @@ -318,7 +321,7 @@ export async function readPackageDirectory(directory, { beforeLeafRead, afterLea continue; } - const snapshot = await snapshotLeaf(full, name, diagnostics, [{ path: directory, stat: root.stat }], FILE_LIMITS[name]); + const snapshot = await snapshotLeaf(full, name, diagnostics, [{ path: directory, stat: root.stat }], fileLimits[name]); if (!snapshot) continue; leaves.push(snapshot); } @@ -336,7 +339,7 @@ export async function readPackageDirectory(directory, { beforeLeafRead, afterLea const rootAfter = await snapshotDirectory("", directory, diagnostics); if (rootAfter) { - compareDirectory(root, rootAfter, diagnostics, (name) => KNOWN_ROOT.has(name)); + compareDirectory(root, rootAfter, diagnostics, (name) => knownRoot.has(name)); } if (examplePath) { diff --git a/src/loops/dsl/package-read.test.mjs b/src/loops/dsl/package-read.test.mjs index c8ca338c..7c1ba7b9 100644 --- a/src/loops/dsl/package-read.test.mjs +++ b/src/loops/dsl/package-read.test.mjs @@ -261,50 +261,24 @@ test("descriptor package diagnostics are merged and truncated after discovery, m const files = await reviewFiles(); const folder = await mkdtemp(join(tmpdir(), "burnlist-loop-package-diagnostics-")); try { - const badCount = 90; + const badCount = 97; const extras = Array.from({ length: badCount }, (_, index) => ` bad-${String(index).padStart(3, "0")}="x"`).join(""); const malformed = files["review.loop"].toString() .replace('version="0.1.0"', "version=\"0\"") - .replace('max-rounds="3"', `max-rounds="0"${extras}`) - .replace('', ''); + .replace('max-rounds="12"', `max-rounds="0"${extras}`) + .replace('', ''); await writeFile(join(folder, "review.loop"), malformed); await writeFile(join(folder, "note.md"), "ignored\n"); const budgetOffset = malformed.indexOf("'); - - const expected = [ - { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }, - { path: "instructions.md", byteOffset: 0, code: "E_PACKAGE_MISSING", message: "Required package file is missing" }, - { path: "note.md", byteOffset: 0, code: "E_PACKAGE_PATH", message: "Unknown package file" }, - { path: "review.loop", byteOffset: 0, code: "E_SCALAR", message: "version must be a Stage 1 SemVer" }, - ...Array.from({ length: badCount }, (_, index) => ({ - path: "review.loop", - byteOffset: budgetOffset, - code: "E_ATTRIBUTE_UNKNOWN", - message: `Attribute bad-${String(index).padStart(3, "0")} is not allowed on `, - })), - { path: "review.loop", byteOffset: budgetOffset, code: "E_SCALAR", message: "max-rounds must be an integer from 1 through 100" }, - { path: "review.loop", byteOffset: implementOffset, code: "E_EDGE_MISSING", message: "Missing edge for implement/complete" }, - { path: "review.loop", byteOffset: verifyOffset, code: "E_REACHABILITY", message: "Node verify is not reachable from entry" }, - { path: "review.loop", byteOffset: reviewOffset, code: "E_REACHABILITY", message: "Node review is not reachable from entry" }, - { path: "review.loop", byteOffset: convergedOffset, code: "E_REACHABILITY", message: "Node converged is not reachable from entry" }, - { path: "review.loop", byteOffset: convergenceDominanceOffset, code: "E_CONVERGENCE_DOMINATION", message: "Only convergence gate pass may target the converged terminal" }, - ]; - const result = await compileLoopPackage(folder); assert.equal(result.ok, false); assert.equal(result.diagnostics.length, 100); - assert.deepEqual(result.diagnostics, expected); + assert.deepEqual(result.diagnostics[0], { path: "", byteOffset: 0, code: "E_TOO_MANY_DIAGNOSTICS", message: "Too many diagnostics (maximum 100)" }); assert.ok(result.diagnostics.some((item) => item.path === "instructions.md" && item.code === "E_PACKAGE_MISSING")); assert.ok(result.diagnostics.some((item) => item.path === "note.md" && item.code === "E_PACKAGE_PATH")); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_UNKNOWN")); - assert.ok(result.diagnostics.some((item) => item.code === "E_CONVERGENCE_DOMINATION")); } finally { await rm(folder, { recursive: true, force: true }); } diff --git a/src/loops/dsl/project-strategies.test.mjs b/src/loops/dsl/project-strategies.test.mjs new file mode 100644 index 00000000..419a6a3a --- /dev/null +++ b/src/loops/dsl/project-strategies.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { assignLoopItem } from "../assignment/assignment.mjs"; +import { resolveLoopAuthority } from "../assignment/resolver.mjs"; +import { compileLoopPackage } from "./compile.mjs"; +import { createRunRunner } from "../run/runner.mjs"; +import { runStore } from "../run/run-store.mjs"; +import { renderResolvedLoopView } from "../view/render.mjs"; + +const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const itemRef = "item:260724-002#L2"; +const runId = "run:01arz3ndektsv4rrffq69g5fav"; + +function fixture(t) { + const repo = mkdtempSync(join(tmpdir(), "burnlist-project-strategy-")); + t.after(() => rmSync(repo, { recursive: true, force: true })); + const plan = join(repo, "notes", "burnlists", "inprogress", "260724-002"); + mkdirSync(plan, { recursive: true }); + writeFileSync(join(plan, "burnlist.md"), "# Test\n\n## Active Checklist\n- [ ] L2 | Project strategy\n\n## Completed\n"); + cpSync(join(projectRoot, ".burnlist", "loops"), join(repo, ".burnlist", "loops"), { recursive: true }); + return repo; +} + +function outcome(nodeId, wholeFirst, attempts) { + if (nodeId.includes("validate")) return "pass"; + if (nodeId.includes("review")) return wholeFirst && nodeId === "review" && attempts === 1 ? "reject" : "approve"; + return "complete"; +} + +async function runGraph(repo, graph, wholeFirst) { + const store = runStore(repo); + store.createRun({ runId, itemRef, graph }); + const seen = []; + const runner = createRunRunner({ store, runId, invoke: async ({ nodeId }) => { + seen.push(nodeId); + return { kind: outcome(nodeId, wholeFirst, seen.filter((id) => id === nodeId).length), summary: nodeId, outputBytes: 0, candidateId: null }; + } }); + const result = await runner.run(); + return { result, seen }; +} + +for (const [name, wholeFirst] of [["whole-first", true], ["block-by-block", false]]) { + test(`${name} is a project-local serial Loop that compiles, assigns, executes, and renders`, async (t) => { + const repo = fixture(t); + const directory = join(repo, ".burnlist", "loops", name); + const compiled = await compileLoopPackage(directory, { loopFile: `${name}.loop` }); + assert.equal(compiled.ok, true, JSON.stringify(compiled.diagnostics)); + + await assignLoopItem({ repoRoot: repo, itemRef, loopRef: `loop:project:${name}` }); + assert.match(readFileSync(join(repo, "notes", "burnlists", "inprogress", "260724-002", "burnlist.md"), "utf8"), new RegExp(`Selector: loop:project:${name}`, "u")); + const authority = await resolveLoopAuthority({ repoRoot: repo, selector: itemRef }); + const rendered = renderResolvedLoopView(authority); + assert.match(rendered, new RegExp(`LOOP: loop:project:${name}`, "u")); + + const { result, seen } = await runGraph(repo, compiled.ir, wholeFirst); + assert.equal(result.projection.state, "converged"); + assert.match(rendered, /DRAWING \(DECORATIVE\):/u); + if (wholeFirst) { + assert.deepEqual(seen, ["implement", "validate", "review", "refine", "validate", "review", "integrate", "final-validate", "final-review"]); + const refine = compiled.ir.edges.filter((edge) => edge.to === "refine"); + assert.deepEqual(refine.map((edge) => `${edge.from}/${edge.on}`).sort(), ["final-review/reject", "final-validate/fail", "review/reject", "validate/fail"]); + } else { + assert.deepEqual(seen, ["plan-blocks", "implement-block-1", "validate-block-1", "review-block-1", "implement-block-2", "validate-block-2", "review-block-2", "implement-block-3", "validate-block-3", "review-block-3", "integrate", "final-validate", "final-review"]); + for (const index of [1, 2, 3]) { + const review = `review-block-${index}`; + const approved = compiled.ir.edges.find((edge) => edge.from === review && edge.on === "approve"); + assert.equal(approved?.to, index === 3 ? "integrate" : `implement-block-${index + 1}`); + } + } + }); +} diff --git a/src/loops/events/activity-projection.mjs b/src/loops/events/activity-projection.mjs new file mode 100644 index 00000000..a2a45814 --- /dev/null +++ b/src/loops/events/activity-projection.mjs @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto"; + +const MAX_ACTIVITY = 10; +const OPTIONAL_KINDS = new Set(["subagent-started", "subagent-finished", "subagent-failed", "tool-started", "tool-finished"]); +const OPTIONAL_KEYS = new Set(["at", "origin", "kind", "provider", "runId", "nodeId", "attempt", "invocationId", "parentAgentId", "subagentId", "tool", "observedPath", "truncated"]); +const safe = (value, maximum = 128) => typeof value === "string" && value.length > 0 && value.length <= maximum && /^[A-Za-z0-9._:/-]+$/u.test(value); +const logicalPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 + && !value.startsWith("/") && !/^[A-Za-z]:/u.test(value) && !value.includes("\\") + && !value.split("/").some((part) => part === "." || part === ".." || !part) + && !/[\u0000-\u001f\u007f]/u.test(value); + +function correlation(runId, nodeId, attempt, invocationId) { + // Bind activity to one exact invocation while keeping its identifier out of + // the observer projection. + const input = `${runId}\0${nodeId}\0${attempt}\0${invocationId}`; + return `ac1-sha256:${createHash("sha256").update(input).digest("hex")}`; +} + +/** + * Normalizes a record after a provider hook has already removed provider raw + * content. This module deliberately has no hook I/O or persistence: callers + * may only supply the narrow structural record below. + */ +export function normalizeOptionalActivityRecord(value) { + if (!value || typeof value !== "object" || Array.isArray(value) + || Object.keys(value).some((key) => !OPTIONAL_KEYS.has(key)) + || !["host-hook", "agent-reported"].includes(value.origin) + || !OPTIONAL_KINDS.has(value.kind) + || !["claude", "codex"].includes(value.provider) + || !safe(value.runId) || !safe(value.nodeId) || !Number.isSafeInteger(value.at) || value.at < 0 + || !Number.isSafeInteger(value.attempt) || value.attempt < 1 || !safe(value.invocationId) + || value.parentAgentId !== undefined && !safe(value.parentAgentId) + || value.subagentId !== undefined && !safe(value.subagentId) + || value.tool !== undefined && !safe(value.tool) + || value.observedPath !== undefined && !logicalPath(value.observedPath) + || value.truncated !== undefined && typeof value.truncated !== "boolean") return null; + if (value.kind.startsWith("subagent-") && !safe(value.subagentId)) return null; + if (value.kind.startsWith("tool-") && !safe(value.tool)) return null; + return Object.freeze({ + at: value.at, origin: value.origin, kind: value.kind, provider: value.provider, + nodeId: value.nodeId, attempt: value.attempt, + correlation: correlation(value.runId, value.nodeId, value.attempt, value.invocationId), + ...(value.parentAgentId ? { parentAgentId: value.parentAgentId } : {}), + ...(value.subagentId ? { subagentId: value.subagentId } : {}), + ...(value.tool ? { tool: value.tool } : {}), + ...(value.observedPath ? { observedPath: value.observedPath } : {}), + ...(value.truncated !== undefined ? { truncated: value.truncated } : {}), + }); +} + +function recordFor({ runId, graph, record, invocations }) { + const { type, payload, at } = record.value; + const node = (id) => graph.nodes.find((entry) => entry.id === id); + const base = (nodeId, attempt, invocationId) => ({ + at, + origin: "runner", + nodeId, + attempt, + correlation: correlation(runId, nodeId, attempt, invocationId), + }); + if (type === "external-claim-bound") { + const current = node(payload.claim.nodeId); + const mode = current?.execution === "managed" ? "managed" : current?.execution === "host" ? "host-reported" : "unavailable"; + invocations.set(payload.invocationId, { nodeId: payload.claim.nodeId, attempt: payload.claim.attempt, mode }); + return { ...base(payload.claim.nodeId, payload.claim.attempt, payload.invocationId), kind: "claimed", mode }; + } + if (type === "invocation-started") { + const current = node(payload.nodeId); + invocations.set(payload.invocationId, { nodeId: payload.nodeId, attempt: payload.attempt, mode: "managed" }); + return { ...base(payload.nodeId, payload.attempt, payload.invocationId), kind: current?.kind === "check" ? "check-started" : "started", mode: "managed", ...(current?.kind === "check" ? { capability: current.capability } : {}) }; + } + if (type === "external-claim-resolved") return { at, origin: "runner", kind: "paused", reason: "paused" }; + if (type === "external-report-accepted" || type === "invocation-result") { + const invocation = invocations.get(payload.invocationId); + if (!invocation) return null; + const current = node(invocation.nodeId); + return { + ...base(invocation.nodeId, invocation.attempt, payload.invocationId), + kind: current?.kind === "check" ? "check-finished" : type === "external-report-accepted" ? "host-result" : "result", + mode: invocation.mode, + outcome: payload.kind, + ...(current?.kind === "check" ? { capability: current.capability } : {}), + }; + } + if (type === "edge-taken") return { at, origin: "runner", kind: "transition", from: payload.from, outcome: payload.on, to: payload.to }; + if (type === "state-changed") return { at, origin: "runner", kind: "lifecycle", state: payload.to }; + if (type === "terminal-node-committed") return { at, origin: "runner", kind: "terminal", state: payload.to }; + return null; +} + +/** + * A deliberately small observer-only activity feed. It derives solely from + * the immutable Run journal: no agent text, command arguments, output, or + * host hook data is accepted here. A future hook reader can append separately + * normalized host-hook/agent-reported records without changing Run authority. + */ +export function projectRunActivity({ runId, graph, journal, optionalRecords = [] }) { + const invocations = new Map(); + const runner = journal.map((record) => recordFor({ runId, graph, record, invocations })).filter(Boolean); + const optional = Array.isArray(optionalRecords) ? optionalRecords + .filter((entry) => entry?.runId === runId + && invocations.get(entry.invocationId)?.nodeId === entry.nodeId + && invocations.get(entry.invocationId)?.attempt === entry.attempt) + .map(normalizeOptionalActivityRecord) + .filter((entry) => entry?.correlation?.startsWith("ac1-sha256:")) : []; + const records = [...runner, ...optional].sort((left, right) => left.at - right.at).slice(-MAX_ACTIVITY); + return Object.freeze({ hooks: optional.length ? "available" : "unavailable", records: Object.freeze(records.map((entry) => Object.freeze(entry))) }); +} diff --git a/src/loops/events/activity-projection.test.mjs b/src/loops/events/activity-projection.test.mjs new file mode 100644 index 00000000..2a658427 --- /dev/null +++ b/src/loops/events/activity-projection.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJournalRecord } from "../run/run-journal.mjs"; +import { normalizeOptionalActivityRecord, projectRunActivity } from "./activity-projection.mjs"; + +const graph = { nodes: [{ id: "implement", kind: "agent", execution: "host" }, { id: "verify", kind: "check", capability: "repo-verify" }], edges: [] }; +const runId = "run:01arz3ndektsv4rrffq69g5fav"; +const claim = { nodeId: "implement", attempt: 1 }; +function journal(type, payload, sequence = 1) { + return createJournalRecord({ sequence, prevDigest: sequence === 1 ? null : `sha256:${"a".repeat(64)}`, at: sequence, type, payload }); +} + +test("activity projection is bounded, correlated, and excludes execution text", () => { + const records = [ + journal("run-created", { schema: "burnlist-loop-m2-run@1", runId, itemRef: "item:260722-001#M7", graph, authorityRequired: false }), + journal("external-claim-bound", { claim: { ...claim, schema: "burnlist-loop-host-claim@1", claimId: `cl1-sha256:${"a".repeat(64)}`, runId, itemRef: "item:260722-001#M7", assignmentId: `as1-sha256:${"b".repeat(64)}`, nodeId: "implement", attempt: 1, issuedAt: 1, expiresAt: 2, executionDigest: `sha256:${"c".repeat(64)}` }, envelopeDigest: `sha256:${"c".repeat(64)}`, invocationId: `iv1-sha256:${"d".repeat(64)}` }, 2), + journal("edge-taken", { from: "implement", on: "complete", to: "verify" }, 3), + ]; + const result = projectRunActivity({ runId, graph, journal: records }); + assert.equal(result.hooks, "unavailable"); + assert.equal(result.records.length, 2); + assert.deepEqual(result.records[0].origin, "runner"); + assert.match(result.records[0].correlation, /^ac1-sha256:/u); + assert.doesNotMatch(JSON.stringify(result), /claimId|invocationId|output|prompt|secret/u); +}); + +test("activity projection keeps only the most recent ten records", () => { + const records = Array.from({ length: 12 }, (_, index) => journal("edge-taken", { from: "a", on: "pass", to: "b" }, index + 1)); + const result = projectRunActivity({ runId, graph, journal: records }); + assert.equal(result.records.length, 10); + assert.equal(result.records[0].at, 3); +}); + +test("optional Claude and Codex observations are narrow, correlated, and preserve parentage", () => { + const claude = normalizeOptionalActivityRecord({ at: 2, origin: "host-hook", kind: "subagent-started", provider: "claude", runId, nodeId: "implement", attempt: 1, invocationId: "invocation-1", parentAgentId: "parent-1", subagentId: "child-1", truncated: true }); + const codex = normalizeOptionalActivityRecord({ at: 3, origin: "agent-reported", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "invocation-1", tool: "node-test", observedPath: "src/loops/run/binder.mjs" }); + assert.deepEqual({ origin: claude.origin, parentAgentId: claude.parentAgentId, subagentId: claude.subagentId, truncated: claude.truncated }, { origin: "host-hook", parentAgentId: "parent-1", subagentId: "child-1", truncated: true }); + assert.equal(codex.tool, "node-test"); + assert.equal(codex.observedPath, "src/loops/run/binder.mjs"); + const invocationId = `iv1-sha256:${"d".repeat(64)}`; + const bound = journal("external-claim-bound", { claim: { ...claim, schema: "burnlist-loop-host-claim@1", claimId: `cl1-sha256:${"a".repeat(64)}`, runId, assignmentId: `as1-sha256:${"b".repeat(64)}`, inputCandidate: `cm1-sha256:${"c".repeat(64)}`, executionDigest: `sha256:${"e".repeat(64)}`, expiresAt: 10 }, envelopeDigest: `sha256:${"e".repeat(64)}`, invocationId }, 1); + const activity = projectRunActivity({ runId, graph, journal: [bound], optionalRecords: [ + { at: 2, origin: "host-hook", kind: "subagent-started", provider: "claude", runId, nodeId: "implement", attempt: 1, invocationId, parentAgentId: "parent-1", subagentId: "child-1", truncated: true }, + { at: 3, origin: "agent-reported", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId, tool: "node-test" }, + ] }); + assert.equal(activity.hooks, "available"); + assert.equal(activity.records.length, 3); + assert.equal(activity.records[1].correlation, activity.records[0].correlation); + assert.notEqual( + normalizeOptionalActivityRecord({ at: 3, origin: "agent-reported", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "another-invocation", tool: "node-test" }).correlation, + activity.records[0].correlation, + ); + const stale = projectRunActivity({ runId, graph, journal: [bound], optionalRecords: [ + { at: 4, origin: "host-hook", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "not-current", tool: "node" }, + ] }); + assert.equal(stale.hooks, "unavailable"); + assert.equal(stale.records.length, 1); + assert.equal(normalizeOptionalActivityRecord({ at: 1, origin: "host-hook", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "x", tool: "node", output: "forbidden" }), null); + assert.equal(normalizeOptionalActivityRecord({ at: 1, origin: "host-hook", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "x", tool: "node", args: ["forbidden"] }), null); + for (const observedPath of ["/private/file", "C:/profile/secret.txt", "C:secret.txt", "../secret", "src/../secret", "src\\secret", "src/\u0000secret"]) { + assert.equal(normalizeOptionalActivityRecord({ at: 1, origin: "host-hook", kind: "tool-finished", provider: "codex", runId, nodeId: "implement", attempt: 1, invocationId: "x", tool: "node", observedPath }), null); + } +}); diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs index 8dfd7adc..817b5ddb 100644 --- a/src/loops/minimal-review-e2e.test.mjs +++ b/src/loops/minimal-review-e2e.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,6 +11,7 @@ import { build } from "esbuild"; import { createProductionRunAuthority, fixtureItemRef } from "./run/run-test-fixtures.mjs"; import { readLatestRunForItem } from "./run/read-projection.mjs"; import { runStore } from "./run/run-store.mjs"; +import { createStoredProductionRunRunner } from "./run/binder.mjs"; import { readOvenEvents } from "../events/oven-event-store.mjs"; import { cliJson, cliOk, request, startCli, waitForExit, waitForFile, withDashboard } from "./minimal-review-e2e-fixtures.mjs"; import { checklistFixture } from "../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; @@ -32,6 +33,35 @@ function edges(projection) { function liveProjection(baseUrl, planPath, headers = {}) { return request(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { headers }); } +function hostResult(execution, outcome) { + return { schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, attempt: execution.attempt, + claimId: execution.claimId, assignmentId: execution.assignmentId, invocationId: execution.invocationId, + recipeRevision: execution.recipeRevision, policyRevision: execution.policyRevision, + inputCandidate: execution.inputCandidate, outcome, findings: [], resolvedFindingIds: [], + }, telemetry: null }; +} +function hostFixture(repo) { + const directory = join(repo, ".burnlist", "loops", "host-review"); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "host-review.loop"), ` + + + + + + +\n`); + writeFileSync(join(directory, "instructions.md"), ["implement", "review", "refine", "integrate", "final-review"].map((id) => `## ${id}\nHost fixture instruction.\n`).join("\n")); + cliOk(repo, ["loop", "unassign", fixtureItemRef]); + cliOk(repo, ["loop", "assign", fixtureItemRef, "loop:project:host-review"]); +} +async function managedCheck(repo, runId, expected) { + const runner = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId }); + await runner.step(); await runner.step(); await runner.step(); + assert.equal(runner.replay().execution.nodeId, expected); + runner.pause(); +} async function dashboardRenderer(t) { const output = await mkdtemp(join(process.cwd(), ".m9-checklist-render-")); t.after(() => rm(output, { recursive: true, force: true })); @@ -51,7 +81,23 @@ async function dashboardRenderer(t) { return candidateAliases.get(id); }; const base = Date.parse("2026-07-15T11:00:00Z"); + const stableActivity = loopRun?.activity && { + ...loopRun.activity, + records: loopRun.activity.records.map((record, index) => ({ + ...record, + at: base + index * 100, + ...(record.correlation ? { correlation: "" } : {}), + })), + }; + const stableTelemetry = loopRun?.execution?.telemetry && { + ...loopRun.execution.telemetry, + startedAt: loopRun.execution.telemetry.startedAt === null ? null : base, + completedAt: loopRun.execution.telemetry.completedAt === null ? null : base + 500, + }; const stableRun = loopRun && { ...loopRun, createdAt: base, updatedAt: base + 4_000, + budget: { ...loopRun.budget, elapsedMilliseconds: 4_000 }, + execution: loopRun.execution && { ...loopRun.execution, telemetry: stableTelemetry }, + activity: stableActivity, latestMaker: loopRun.latestMaker && { ...loopRun.latestMaker, at: base + 1_000, candidateId: alias(loopRun.latestMaker.candidateId) }, latestCheck: loopRun.latestCheck && { ...loopRun.latestCheck, at: base + 2_000, candidateId: alias(loopRun.latestCheck.candidateId) }, latestReviewer: loopRun.latestReviewer && { ...loopRun.latestReviewer, at: base + 3_000, candidateId: alias(loopRun.latestReviewer.candidateId) } }; @@ -79,19 +125,16 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch const absent = await liveProjection(baseUrl, planPath); assert.equal(absent.status, 200); assert.equal(JSON.parse(absent.body).loopRun, null); const view = cliOk(repo, ["loop", "view", fixtureItemRef]); - assert.match(view, /^MODE: ITEM-PINNED$/mu); assert.match(view, /implement.*verify.*review/su); + assert.match(view, /^MODE: ITEM-PINNED$/mu); assert.match(view, /decompose.*implement.*validate.*review/su); const escalation = cliJson(repo, ["loop", "create", fixtureItemRef]).runId, escalationCounter = join(directory, "escalation-counter"); writeFileSync(escalationCounter, "0"); const escalated = cliJson(repo, ["loop", "run", escalation], { - BURNLIST_FAKE_COUNTER: escalationCounter, BURNLIST_FAKE_OUTCOMES: "complete,escalate", + BURNLIST_FAKE_COUNTER: escalationCounter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,escalate", }); assert.equal(escalated.state, "needs-human"); const escalationInspection = cliJson(repo, ["loop", "inspect", escalation]); - assert.deepEqual(edges(escalationInspection), [ - { from: "implement", outcome: "complete", to: "verify" }, { from: "verify", outcome: "pass", to: "review" }, - { from: "review", outcome: "escalate", to: "needs-human" }, - ]); + assert.deepEqual(edges(escalationInspection).slice(-1), [{ from: "review", outcome: "escalate", to: "needs-human" }]); const escalationHttp = await liveProjection(baseUrl, planPath); assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; assert.deepEqual(escalationProjection, escalationInspection); @@ -102,92 +145,90 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch assert.equal(existsSync(join(repo, ".local", "burnlist", "loop", "m2", "runs", Buffer.from(escalation).toString("hex"), "completion-receipt.json")), false); assert.match(readFileSync(planPath, "utf8"), /- \[ \] L29/u); - const runId = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; + const interruptedRun = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; const counter = join(directory, "counter"), started = join(directory, "started.json"); writeFileSync(counter, "0"); - const first = startCli(repo, ["loop", "run", runId], { + const first = startCli(repo, ["loop", "run", interruptedRun], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve", BURNLIST_FAKE_STARTED: started, BURNLIST_FAKE_WAIT_MS: "1000", }); await waitForFile(started, first); const active = JSON.parse(readFileSync(started, "utf8")); assert.equal(existsSync(`${started}.${active.pid}.tmp`), false, "ready marker is atomically published"); - assert.equal(active.node, "implement"); assert.equal(first.kill("SIGINT"), true); + assert.equal(active.node, "start"); assert.equal(first.kill("SIGINT"), true); const interrupted = await waitForExit(first); assert.equal(interrupted.code, 0, interrupted.stderr); - const paused = JSON.parse(interrupted.stdout); - assert.equal(paused.state, "paused"); assert.equal(paused.currentNode, "implement"); assert.equal(paused.attempt, 1); + const interruptedProjection = JSON.parse(interrupted.stdout); + // The fake Codex executable cannot prove descendant cleanup. That is a + // lost external owner, not a resumable pause. + assert.equal(interruptedProjection.state, "needs-human"); assert.throws(() => process.kill(active.pid, 0), { code: "ESRCH" }); - const pausedInspection = cliJson(repo, ["loop", "inspect", runId]); - const pausedStatus = cliJson(repo, ["loop", "status", runId]); - for (const projection of [pausedInspection, pausedStatus]) { + const interruptedInspection = cliJson(repo, ["loop", "inspect", interruptedRun]); + const interruptedStatus = cliJson(repo, ["loop", "status", interruptedRun]); + for (const projection of [interruptedInspection, interruptedStatus]) { assert.equal(projection.loopId, "review"); assert.match(projection.loopRevision, /^er1-sha256:[a-f0-9]{64}$/u); assert.equal(Number.isSafeInteger(projection.createdAt), true); assert.equal(Number.isSafeInteger(projection.updatedAt), true); assert.ok(projection.updatedAt >= projection.createdAt); } - assert.equal(pausedStatus.state, "paused"); assert.equal(pausedStatus.currentNode, "implement"); - assert.equal(pausedInspection.latestResult, null); - assert.equal(pausedInspection.transitions.length, 2, "only prepared→running and running→paused are durable"); - const pausedRaw = runStore(repo).read(runId); - const pausedPrefix = pausedRaw.journal.map((record) => record.bytes.toString("utf8")); - const firstImplement = pausedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement" && record.value.payload.attempt === 1); - assert.equal(firstImplement.length, 1); assert.equal(pausedRaw.journal.filter((record) => record.value.type === "invocation-result" && record.value.payload.invocationId === firstImplement[0].value.payload.invocationId).length, 0); - assert.equal(pausedRaw.execution.invocation, null); assert.equal(pausedRaw.projection.leaseHeld, false); - const pausedHttp = await liveProjection(baseUrl, planPath); - assert.equal(pausedHttp.status, 200); const pausedProjection = JSON.parse(pausedHttp.body).loopRun; - assert.deepEqual(pausedProjection, pausedInspection); - const afterPause = await liveProjection(baseUrl, planPath, { "if-none-match": pausedHttp.headers.etag }); - assert.equal(afterPause.status, 304); - - const repairStarted = join(directory, "repair-started.json"); - const repair = startCli(repo, ["loop", "resume", runId], { - BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve", - BURNLIST_FAKE_STARTED: repairStarted, BURNLIST_FAKE_WAIT_MS: "1000", - }); - for (;;) { - await waitForFile(repairStarted, repair); - const marker = JSON.parse(readFileSync(repairStarted, "utf8")); - if (marker.node === "implement" && marker.attempt === 2) { repair.kill("SIGINT"); break; } - await new Promise((done) => setTimeout(done, 10)); - } - const repairExit = await waitForExit(repair); - assert.equal(repairExit.code, 0, repairExit.stderr); - const repairProjection = JSON.parse(repairExit.stdout); - assert.equal(repairProjection.state, "paused"); assert.equal(repairProjection.currentNode, "implement"); - assert.equal(repairProjection.attempt, 2); assert.deepEqual(repairProjection.latestResult, { kind: "reject", summary: "fake reject" }); - const repairHttp = await liveProjection(baseUrl, planPath, { "if-none-match": pausedHttp.headers.etag }); - assert.equal(repairHttp.status, 200); assert.deepEqual(JSON.parse(repairHttp.body).loopRun, repairProjection); + assert.equal(interruptedStatus.state, "needs-human"); + assert.equal(interruptedInspection.latestResult, null); + const interruptedRaw = runStore(repo).read(interruptedRun); + assert.ok(interruptedRaw.execution.externalClaim, "lost ownership remains visible for human recovery"); + assert.equal(interruptedRaw.projection.leaseHeld, false); + assert.equal(interruptedRaw.journal.some((record) => record.value.type === "external-claim-bound"), true); + assert.equal(interruptedRaw.journal.some((record) => record.value.type === "external-claim-resolved"), false); - const completed = cliJson(repo, ["loop", "resume", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,reject,complete,approve" }); + // A closed lost Run may be replaced by a fresh Run. The normal E2E then + // exercises reject/repair/convergence without pretending fake cleanup is + // resumable. + const runId = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; + writeFileSync(counter, "0"); + const completed = cliJson(repo, ["loop", "run", runId], { BURNLIST_FAKE_COUNTER: counter, + BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,reject,complete,complete,approve,complete,approve" }); assert.equal(completed.state, "converged"); assert.equal(completed.currentNode, "completed"); assert.deepEqual(edges(completed), [ - { from: "implement", outcome: "complete", to: "verify" }, { from: "verify", outcome: "pass", to: "review" }, - { from: "review", outcome: "reject", to: "implement" }, { from: "implement", outcome: "complete", to: "verify" }, - { from: "verify", outcome: "pass", to: "review" }, { from: "review", outcome: "approve", to: "converged" }, + { from: "start", outcome: "complete", to: "decompose" }, + { from: "decompose", outcome: "complete", to: "implement" }, + { from: "implement", outcome: "complete", to: "validate" }, + { from: "validate", outcome: "pass", to: "review" }, + { from: "review", outcome: "reject", to: "decompose" }, + { from: "decompose", outcome: "complete", to: "implement" }, + { from: "implement", outcome: "complete", to: "validate" }, + { from: "validate", outcome: "pass", to: "review" }, + { from: "review", outcome: "approve", to: "integrate" }, + { from: "integrate", outcome: "complete", to: "final-validate" }, + { from: "final-validate", outcome: "pass", to: "final-review" }, + { from: "final-review", outcome: "approve", to: "converged" }, { from: "converged", outcome: "pass", to: "completed" }, ]); const completedRaw = runStore(repo).read(runId); - assert.deepEqual(completedRaw.journal.slice(0, pausedPrefix.length).map((record) => record.bytes.toString("utf8")), pausedPrefix); - const implementInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "implement"); - const checkInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "verify"); - const reviewerInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started" && record.value.payload.nodeId === "review"); - assert.equal(implementInvocations.length, 4); assert.equal(reviewerInvocations.length, 2); - assert.deepEqual(implementInvocations.map((record) => record.value.payload.attempt), [1, 1, 2, 2]); + const agentClaims = completedRaw.journal.filter((record) => record.value.type === "external-claim-bound"); + const checkInvocations = completedRaw.journal.filter((record) => record.value.type === "invocation-started"); + const reviewerClaims = agentClaims.filter((record) => ["review", "final-review"].includes(record.value.payload.claim.nodeId)); + assert.equal(agentClaims.length, 9); assert.equal(reviewerClaims.length, 3); + assert.deepEqual(agentClaims.filter((record) => record.value.payload.claim.nodeId === "implement") + .map((record) => record.value.payload.claim.attempt), [1, 2]); const candidates = completedRaw.journal.filter((record) => record.value.type === "candidate-bound").map((record) => record.value.payload.candidateId); - assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1], "repair publishes a fresh repository candidate"); + assert.equal(candidates.length, 6); + assert.equal(new Set(candidates).size, 2, "repair publishes a fresh repository candidate"); const resultCandidate = (startedRecord) => completedRaw.journal.find((record) => record.value.type === "invocation-result" && record.value.payload.invocationId === startedRecord.value.payload.invocationId)?.value.payload.candidateId; - assert.deepEqual(checkInvocations.map(resultCandidate), candidates, "each trusted check is bound to its maker candidate"); - assert.deepEqual(reviewerInvocations.map(resultCandidate), candidates, "each reviewer result is bound to its checked candidate"); - const invocationIds = [...implementInvocations, ...reviewerInvocations].map((record) => record.value.payload.invocationId); + assert.deepEqual(checkInvocations.map(resultCandidate), [candidates[2], candidates[4], candidates[5]], + "each trusted check is bound to the latest candidate"); + const reportCandidate = (claimRecord) => completedRaw.journal.find((record) => + record.value.type === "external-report-accepted" + && record.value.payload.claimId === claimRecord.value.payload.claim.claimId)?.value.payload.candidateId; + assert.deepEqual(reviewerClaims.map(reportCandidate), [candidates[2], candidates[4], candidates[5]], + "each reviewer report is bound to its checked candidate"); + const invocationIds = agentClaims.map((record) => record.value.payload.invocationId); assert.equal(new Set(invocationIds).size, invocationIds.length, "every agent invocation id is globally unique"); - assert.equal(readFileSync(counter, "utf8"), "4"); + assert.equal(readFileSync(counter, "utf8"), "9"); const beforeRestart = cliJson(repo, ["loop", "inspect", runId]); assert.deepEqual(cliJson(repo, ["loop", "run", runId]), completed, "terminal restart is an idempotent read"); assert.deepEqual(cliJson(repo, ["loop", "inspect", runId]), beforeRestart, "terminal restart writes no journal records"); - const convergedHttp = await liveProjection(baseUrl, planPath, { "if-none-match": repairHttp.headers.etag }); + const convergedHttp = await liveProjection(baseUrl, planPath); assert.equal(convergedHttp.status, 200); const convergedProjection = JSON.parse(convergedHttp.body).loopRun; assert.deepEqual(convergedProjection, completed); const replay = readLatestRunForItem({ repoRoot: repo, itemRef: fixtureItemRef }); @@ -195,10 +236,9 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch const invalidations = readOvenEvents(repo, { ovenIds: ["checklist"] }).filter((event) => event.kind === "loop-projection-changed" && event.cursor === completed.revision); assert.equal(invalidations.length, 1); assert.deepEqual(invalidations[0].payload, { revision: completed.revision }); - const ui = [render("paused", pausedProjection), render("repair", repairProjection), render("converged", convergedProjection)]; const domGolden = JSON.parse(await readFile(domGoldenPath, "utf8")); assert.deepEqual(needsHumanUi.record, domGolden[0]); - assert.deepEqual(ui.map((item) => item.record), domGolden.slice(1, 4)); + assert.deepEqual(render("converged", convergedProjection).record, domGolden[3]); const firstCompletion = cliJson(repo, ["loop", "complete", runId]); const secondCompletion = cliJson(repo, ["loop", "complete", runId]); @@ -216,3 +256,69 @@ test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch assert.match(readFileSync(planPath, "utf8"), /^- DIRECT-01 \| .* \| Unassigned direct-flow control$/mu); }); }); + +test("H9 host claims survive restart, reject drift, repair, converge, and complete", { timeout: 60_000 }, async (t) => { + const directory = mkdtempSync(join(tmpdir(), "burnlist-h9-host-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); + hostFixture(repo); + const runId = cliJson(repo, ["loop", "create", fixtureItemRef]).runId; + const reportPath = join(directory, "host-report.json"), observed = []; + const report = (claim, outcome) => { + writeFileSync(reportPath, `${JSON.stringify(hostResult(claim, outcome))}\n`); + return cliJson(repo, ["loop", "report", claim.claimId, "--result", reportPath]); + }; + { + const observe = async (node) => { + const projection = readLatestRunForItem({ repoRoot: repo, itemRef: fixtureItemRef }); + assert.ok(projection); + assert.equal(projection.currentNode, node); + assert.equal(projection.graph.nodes.find((item) => item.id === node)?.executionMode ?? "managed", ["implement", "review", "refine", "integrate", "final-review"].includes(node) ? "host" : "managed"); + observed.push(node); + }; + await observe("implement"); + const implement = cliJson(repo, ["loop", "claim", runId]).execution; + assert.equal(implement.nodeId, "implement"); + assert.equal(cliJson(repo, ["loop", "status", runId]).currentNode, "implement", "separate CLI restart preserves the durable claim"); + report(implement, "complete"); + await observe("validate"); + await managedCheck(repo, runId, "review"); + await observe("review"); + + const firstReview = cliJson(repo, ["loop", "claim", runId]).execution; + const drift = join(repo, "src", "host-report-drift.txt"); + writeFileSync(drift, "workspace drift\n"); + writeFileSync(reportPath, `${JSON.stringify(hostResult(firstReview, "reject"))}\n`); + assert.throws(() => cliJson(repo, ["loop", "report", firstReview.claimId, "--result", reportPath]), /candidate drifted/u); + assert.equal(cliJson(repo, ["loop", "status", runId]).currentNode, "review"); + rmSync(drift); + report(firstReview, "reject"); + await observe("refine"); + assert.throws(() => cliJson(repo, ["loop", "complete", runId]), /not converged/u); + + writeFileSync(join(repo, "src", "host-repair.txt"), "repaired candidate\n"); + const refine = cliJson(repo, ["loop", "claim", runId]).execution; + assert.notEqual(refine.inputCandidate, implement.inputCandidate, "repair must claim a freshly derived candidate"); + report(refine, "complete"); + await observe("validate"); + await managedCheck(repo, runId, "review"); + const review = cliJson(repo, ["loop", "claim", runId]).execution; + assert.equal(review.inputCandidate, refine.inputCandidate, "review is bound to the repaired candidate"); + report(review, "approve"); + await observe("integrate"); + const integrate = cliJson(repo, ["loop", "claim", runId]).execution; + report(integrate, "complete"); + await observe("final-validate"); + await managedCheck(repo, runId, "final-review"); + const finalReview = cliJson(repo, ["loop", "claim", runId]).execution; + report(finalReview, "approve"); + await observe("converged"); + const runner = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId }); + await runner.step(); await runner.step(); await runner.step(); await runner.step(); + assert.equal(cliJson(repo, ["loop", "status", runId]).state, "converged"); + assert.deepEqual(observed, ["implement", "validate", "review", "refine", "validate", "integrate", "final-validate", "converged"]); + assert.equal(cliJson(repo, ["loop", "complete", runId]).alreadyApplied, false); + assert.doesNotMatch(readFileSync(planPath, "utf8"), /- \[ \] L29/u); + } +}); diff --git a/src/loops/run/binder.mjs b/src/loops/run/binder.mjs index 7151b723..0545fdfa 100644 --- a/src/loops/run/binder.mjs +++ b/src/loops/run/binder.mjs @@ -3,7 +3,8 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { findBurnlistDir } from "../../cli/lifecycle-moves.mjs"; import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadata.mjs"; -import { parseItemRef } from "../assignment/selectors.mjs"; +import { parseItemRef, parseLoopRef } from "../assignment/selectors.mjs"; +import { resolveLoopPackage } from "../assignment/assignment.mjs"; import { assignmentStore } from "../assignment/store.mjs"; import { readCapabilityCatalog, resolveCapability, canonicalCapabilityBytes, canonicalGrantBytes, GUARANTEE_LABELS } from "../capabilities/contract.mjs"; import { assertTrustedCapability } from "../capabilities/trust.mjs"; @@ -11,15 +12,13 @@ import { checkSnapshot, holdSnapshot, readSnapshotBytes, releaseSnapshot, snapsh import { compileLoopFiles } from "../dsl/compile.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { prefixed, rawSha256 } from "../dsl/hash.mjs"; -import { createNormalizedInvocation } from "../adapters/normalized-invocation.mjs"; import { agentProfileRevision } from "../agents/profile.mjs"; import { readProfile, readRoute, requiredRoutes } from "../config/profiles.mjs"; import { localRecordPath } from "../config/store.mjs"; -import { boundPolicyRevision, canonicalBoundPolicyBytes, loadBoundPolicy } from "./run-artifacts.mjs"; -import { createRunRunner } from "./runner.mjs"; +import { canonicalBoundPolicyBytes, loadBoundPolicy } from "./run-artifacts.mjs"; import { deriveCandidate } from "./candidate.mjs"; -import { ownerClaimId } from "./run-claim.mjs"; import { newRunId } from "./run-codec.mjs"; +import { createBoundNormalizedInvocationImpl, createProductionRunRunnerImpl } from "./production-runner.mjs"; const INPUT_KEYS = new Set(["runId", "itemRef"]); const builtinsRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops"); @@ -83,6 +82,24 @@ function executableSnapshot(loopRef) { assertBoundaryEvidence(evidence); return { compiled, evidence }; } +async function projectExecutableSnapshot(repoRoot, loopRef) { + const loop = parseLoopRef(loopRef), directory = join(repoRoot, ".burnlist", "loops", loop.name); + const resolved = await resolveLoopPackage({ repoRoot, loop }); + const files = {}, evidence = []; + for (const name of Object.keys(resolved.packageFiles).sort()) { + const path = join(directory, name), maximum = name.endsWith(".loop") ? 65536 : name === "instructions.md" ? 262144 : 65536; + const captured = readSnapshotBytes({ root: repoRoot, path, maximum }); files[name] = captured.bytes; + evidence.push(Object.freeze({ kind: "file", path, dev: String(captured.identity.dev), ino: String(captured.identity.ino), + size: String(captured.identity.size), mode: String(captured.identity.mode), mtimeMs: String(captured.identity.mtimeMs), ctimeMs: String(captured.identity.ctimeMs) })); + for (const ancestor of captured.ancestors) evidence.push(Object.freeze({ kind: "directory", path: ancestor.path, + dev: String(ancestor.identity.dev), ino: String(ancestor.identity.ino), size: String(ancestor.identity.size), + mode: String(ancestor.identity.mode), mtimeMs: String(ancestor.identity.mtimeMs), ctimeMs: String(ancestor.identity.ctimeMs) })); + } + const compiled = compileLoopFiles(files, { loopFile: `${loop.name}.loop` }); + if (!compiled.ok) fail("captured project executable source does not compile"); + assertBoundaryEvidence(evidence); + return { compiled, evidence }; +} function currentPolicy(repoRoot, recipeRevision) { const authorityInputs = []; const add = (role, path, executable = false) => authorityInputs.push(Object.freeze({ role, path, executable })); @@ -139,10 +156,12 @@ export function launchAuthorityDigest(evidence) { return rawSha256(Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-launch-authority@1", inputs })}\n`)); } export function assertExecutableBinding(authority) { - if (!authority?.artifact?.executionRevision || !authority.currentCompiled?.revisions?.executable) + if (!authority?.artifact?.executionRevision || !authority?.artifact?.packageRevision || !authority.currentCompiled?.revisions?.executable || !authority.currentCompiled?.revisions?.package) fail("installed executable recipe is unavailable"); if (authority.currentCompiled.revisions.executable !== authority.artifact.executionRevision) fail(`installed executable ${authority.currentCompiled.revisions.executable} does not match assignment pin ${authority.artifact.executionRevision}`, "ELOOP_RUN_EXECUTABLE_DRIFT"); + if (authority.currentCompiled.revisions.package !== authority.artifact.packageRevision) + fail(`installed package ${authority.currentCompiled.revisions.package} does not match assignment pin ${authority.artifact.packageRevision}`, "ELOOP_RUN_EXECUTABLE_DRIFT"); return authority.artifact.executionRevision; } @@ -157,7 +176,9 @@ export async function bindRunCreation({ repoRoot, input }) { || artifact.unassignedItemDigest !== metadata.unassignedDigest || artifact.executionRevision !== metadata["Execution-Revision"] || artifact.packageRevision !== metadata["Package-Revision"]) fail("Run creation requires one canonical item assignment"); - const source = executableSnapshot(artifact.selector); + const source = artifact.selector.startsWith("loop:builtin:") + ? executableSnapshot(artifact.selector) + : await projectExecutableSnapshot(repoRoot, artifact.selector); const recipeRevision = assertExecutableBinding({ artifact, currentCompiled: source.compiled }); const policy = currentPolicy(repoRoot, recipeRevision); const evidence = boundaryEvidence([join(located.dir, "burnlist.md"), @@ -200,9 +221,18 @@ export async function createProductionRun({ repoRoot, store, itemRef, runId = ne authority = await bindRunCreation({ repoRoot, input: { runId, itemRef } }); } } - revalidatePreparedBinding({ repoRoot, bound: authority }); const graph = loadFrozenRecipe(authority.frozenRecipeBytes).ir; - store.createRun({ runId, itemRef: authority.itemRef, graph, authority: sealRunAuthority(runId, authority) }); + const previous = store.readCurrentRun?.(authority.itemRef); + let allowSupersedeConverged = false; + if (previous && previous.runId !== runId) { + const replay = store.read(previous.runId); + if (replay.projection.state === "converged") { + const candidate = deriveCandidate({ repoRoot }); + allowSupersedeConverged = candidate.id !== replay.execution.candidate?.id; + } + } + revalidatePreparedBinding({ repoRoot, bound: authority }); + store.createRun({ runId, itemRef: authority.itemRef, graph, authority: sealRunAuthority(runId, authority), allowSupersedeConverged }); return store.read(runId); } @@ -231,7 +261,7 @@ function revalidateAssignedItem({ repoRoot, replay }) { if (located.lifecycle.folder !== "inprogress") fail("Run item is no longer active", "ELOOP_RUN_BINDING_STALE"); const span = locateItemSpan(readFileSync(join(located.dir, "burnlist.md")), item.itemId); const metadata = validateAssignedItem(item.selector, span), artifact = assignmentStore(repoRoot).load(metadata["Assignment-Id"]); - const expectedSelector = `loop:builtin:${replay.frozenRecipe.ir.id}`; + const expectedSelector = artifact.selector; if (metadata.assignedDigest !== replay.projection.itemRevision || metadata["Assignment-Id"] !== replay.projection.assignmentId || metadata.Selector !== expectedSelector @@ -296,62 +326,18 @@ export function releaseRunLaunchBinding(held) { */ export function createBoundNormalizedInvocation({ repoRoot, replay, contextFor, candidateForBoundary = null, startAgent, runCheck, agentTimeoutMs = 0 }) { - if (typeof repoRoot !== "string" || !replay?.projection?.assignmentId || !replay?.frozenRecipe?.ir - || typeof replay.itemText !== "string" || !replay.itemText - || !Buffer.isBuffer(replay.policyBytes) || typeof contextFor !== "function") fail("invalid production invocation input"); - const policy = loadBoundPolicy(replay.policyBytes).policy; - const route = (name) => policy.routes.find((entry) => entry.route === name); - const implementation = route("implementation.standard"), review = route("review.strong"); - if (!implementation || !review) fail("frozen Stage One routes are unavailable"); - const nodes = new Map(replay.frozenRecipe.ir.nodes.map((node) => [node.id, node])); - return createNormalizedInvocation({ repoRoot, nodes, - routes: { implementation: { profile: implementation.profile }, review: { profile: review.profile } }, - bindingFor(invocation, node) { - const context = contextFor(invocation, node), instruction = replay.frozenRecipe.instructions - .find((item) => item.id === node.instructions); - if (!context || node.kind === "agent" && !instruction) fail("frozen invocation context is unavailable"); - return { claimId: context.claimId, assignmentId: replay.projection.assignmentId, - recipeRevision: replay.frozenRecipe.revisions.executable, policyRevision: boundPolicyRevision(policy), - inputCandidate: context.inputCandidate, instructionBytes: instruction - ? Buffer.from(instruction.base64, "base64").toString("utf8") : "Run the frozen trusted capability.\n", - itemText: replay.itemText, candidateContext: context.candidateContext, - reviewerEvidence: context.reviewerEvidence ?? [] }; - }, candidateForBoundary, startAgent, runCheck, agentTimeoutMs }); + return createBoundNormalizedInvocationImpl({ repoRoot, replay, contextFor, candidateForBoundary, + startAgent, runCheck, agentTimeoutMs }); } /** Compose frozen creation authority, the M3 dispatcher, and the M2 runner. */ export function createProductionRunRunner({ repoRoot, store, runId, authority, contextFor, startAgent, runCheck, agentTimeoutMs = 0 }) { - if (authority?.schema === "burnlist-loop-m12-run-authority@1") authority = unsealRunAuthority(authority); - if (!store?.replay || !authority?.assignmentId || !Buffer.isBuffer(authority.frozenRecipeBytes) - || !Buffer.isBuffer(authority.policyBytes)) fail("invalid production runner authority"); - const frozenRecipe = loadFrozenRecipe(authority.frozenRecipeBytes); - const replay = { projection: { assignmentId: authority.assignmentId }, frozenRecipe, - policyBytes: authority.policyBytes, itemText: authority.itemText }; - const liveContext = (invocation, node) => { - const execution = store.replay(runId).execution; - const candidate = execution.candidate ?? deriveCandidate({ repoRoot }); - const checkNode = frozenRecipe.ir.nodes.find((item) => item.kind === "check"); - const check = checkNode && execution.evidence[checkNode.id]; - const reviewerEvidence = node.mode === "review" - ? check?.kind === "pass" && check.candidateId === candidate.id && execution.latest.check?.candidateId === candidate.id - ? [`trusted-check candidate=${candidate.id} summary=${execution.latest.check.summary}`] : [] - : []; - return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, attempt: invocation.attempt, - assignmentId: authority.assignmentId, inputCandidate: candidate.id }), inputCandidate: candidate.id, - candidateContext: candidate.context, reviewerEvidence }; - }; - const dispatch = createBoundNormalizedInvocation({ repoRoot, replay, contextFor: contextFor ?? liveContext, - candidateForBoundary: () => deriveCandidate({ repoRoot }), startAgent, runCheck, agentTimeoutMs }); - const invoke = async (invocation) => { - const captured = captureRunLaunchBinding({ repoRoot, replay: { ...replay, projection: { ...replay.projection, itemRef: authority.itemRef, itemRevision: authority.itemRevision }, boundPolicy: loadBoundPolicy(authority.policyBytes).policy } }); - recheckRunLaunchBinding(captured); const held = holdRunLaunchBinding(captured); - try { recheckRunLaunchBinding(captured); return await dispatch(invocation); } - finally { releaseRunLaunchBinding(held); } - }; - return createRunRunner({ store, runId, invoke, bindCandidate() { - const candidate = deriveCandidate({ repoRoot }); return { candidateId: candidate.id, candidateContext: candidate.context }; - } }); + return createProductionRunRunnerImpl({ repoRoot, store, runId, authority, contextFor, + startAgent, runCheck, agentTimeoutMs, binding: { + seal: sealRunAuthority, unseal: unsealRunAuthority, capture: captureRunLaunchBinding, + recheck: recheckRunLaunchBinding, hold: holdRunLaunchBinding, release: releaseRunLaunchBinding, + } }); } /** Resume constructs exclusively from the immutable per-Run record; it never rebinds source or policy. */ diff --git a/src/loops/run/binder.test.mjs b/src/loops/run/binder.test.mjs index f8e44551..e1dbaedd 100644 --- a/src/loops/run/binder.test.mjs +++ b/src/loops/run/binder.test.mjs @@ -1,15 +1,49 @@ import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { bindRunCreation, createProductionRun, createProductionRunRunner, createStoredProductionRunRunner, revalidatePreparedBinding } from "./binder.mjs"; +import { assignLoopItem, prepareItemMutation, unassignLoopItem } from "../assignment/assignment.mjs"; import { loadBoundPolicy } from "./run-artifacts.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { runStore } from "./run-store.mjs"; import { presentRun } from "./read-projection.mjs"; import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "./run-test-fixtures.mjs"; +async function projectLoop(repo) { + unassignLoopItem({ repoRoot: repo, itemRef: fixtureItemRef }); + const root = join(repo, ".burnlist", "loops", "whole-first"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "whole-first.loop"), readFileSync(new URL("../../../loops/review/review.loop", import.meta.url)), { flag: "wx" }); + writeFileSync(join(root, "instructions.md"), readFileSync(new URL("../../../loops/review/instructions.md", import.meta.url)), { flag: "wx" }); + await assignLoopItem({ repoRoot: repo, itemRef: fixtureItemRef, loopRef: "loop:project:whole-first", prepared: prepareItemMutation({ repoRoot: repo, itemRef: fixtureItemRef }) }); + return root; +} + +test("project package publication is source-bound while a published Run remains frozen", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-project-bind-"))); t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), root = await projectLoop(repo); + const bound = await bindRunCreation({ repoRoot: repo, input: { runId: fixtureRunId, itemRef: fixtureItemRef } }); + const source = join(root, "whole-first.loop"), original = readFileSync(source), replacement = `${source}.replacement`; + writeFileSync(replacement, `${original}\n`); renameSync(replacement, source); + assert.throws(() => revalidatePreparedBinding({ repoRoot: repo, bound }), /authority input changed before Run publication/u); + writeFileSync(source, original); + const created = await createProductionRun({ repoRoot: repo, store: runStore(repo), itemRef: fixtureItemRef, runId: fixtureRunId }); + assert.equal(created.projection.runId, fixtureRunId); + writeFileSync(source, `${readFileSync(source, "utf8")}\n`); + const outcomes = ["complete", "complete", "complete", "reject", "complete", "complete", "approve", "complete", "approve"]; + const runner = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId: fixtureRunId, + startAgent: ({ prompt }) => { + const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); + const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), claimId: values.claim, + invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, policyRevision: values.policy, + inputCandidate: values.candidate, outcome: outcomes.shift(), summary: "frozen project recipe", findings: [], resolvedFindingIds: [] }; + return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; + }, runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("pass") }) }); + assert.equal((await runner.run()).projection.state, "converged"); +}); + test("production creation binds direct Stage One profiles without Docker artifacts", async (t) => { const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-direct-binder-"))); t.after(() => rmSync(directory, { recursive: true, force: true })); @@ -46,7 +80,8 @@ test("production factory drives direct maker-check-reject-repair-approve", async store.createRun({ runId: fixtureRunId, itemRef: fixtureItemRef, graph }); const counter = join(directory, "counter"); writeFileSync(counter, "0"); const oldCounter = process.env.BURNLIST_FAKE_COUNTER, oldOutcomes = process.env.BURNLIST_FAKE_OUTCOMES; - process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,reject,complete,approve"; + process.env.BURNLIST_FAKE_COUNTER = counter; + process.env.BURNLIST_FAKE_OUTCOMES = "complete,complete,complete,reject,complete,complete,approve,complete,approve"; t.after(() => { if (oldCounter === undefined) delete process.env.BURNLIST_FAKE_COUNTER; else process.env.BURNLIST_FAKE_COUNTER = oldCounter; if (oldOutcomes === undefined) delete process.env.BURNLIST_FAKE_OUTCOMES; else process.env.BURNLIST_FAKE_OUTCOMES = oldOutcomes; @@ -62,7 +97,7 @@ test("production factory drives direct maker-check-reject-repair-approve", async test("executable reviewer escalation and malformed or stale finals fail closed", async (t) => { for (const [name, outcomes, mode, expected] of [ - ["escalate", "complete,escalate", "", "needs-human"], + ["escalate", "complete,complete,complete,escalate", "", "needs-human"], ["malformed", "complete", "malformed", "failed"], ["stale", "complete", "stale", "failed"], ]) { @@ -101,26 +136,29 @@ test("stored production repair binds a fresh repository candidate and gives each assert.deepEqual(publicRun.graph.nodes.find((node) => node.id === "review").execution, { profileId: "reviewer", model: "gpt-5.3-codex-spark", effort: "medium", authority: "read" }); assert.doesNotMatch(JSON.stringify(publicRun), /\/bin\/sh|executableDigest|adapter/u); - const outcomes = ["complete", "reject", "complete", "approve"], reviewerPrompts = []; let writes = 0; + const outcomes = ["complete", "complete", "complete", "reject", "complete", "complete", "approve", "complete", "approve"]; + const reviewerPrompts = []; let writes = 0; const startAgent = ({ prompt }) => { const values = Object.fromEntries(prompt.split("\n").filter((line) => line.includes("=")).map((line) => line.split(/=(.*)/su).slice(0, 2))); const outcome = outcomes.shift(); if (values.node === "implement") writeFileSync(join(repo, "candidate-state.txt"), `maker-write-${++writes}\n`); - if (values.node === "review") reviewerPrompts.push(prompt); + if (values.node === "review" || values.node === "final-review") reviewerPrompts.push(prompt); const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), claimId: values.claim, invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, - policyRevision: values.policy, inputCandidate: values.candidate, outcome, summary: `fake ${outcome}` }; + policyRevision: values.policy, inputCandidate: values.candidate, outcome, summary: `fake ${outcome}`, findings: [], resolvedFindingIds: [] }; return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; }; const runner = createStoredProductionRunRunner({ repoRoot: repo, store, runId: created.projection.runId, startAgent, runCheck: async ({ inputCandidate }) => ({ result: { outcome: "pass", inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("pass") }) }); assert.equal((await runner.run()).projection.state, "converged"); const candidates = store.read(fixtureRunId).journal.filter((record) => record.value.type === "candidate-bound").map((record) => record.value.payload.candidateId); - assert.equal(candidates.length, 2); assert.notEqual(candidates[0], candidates[1]); - assert.equal(reviewerPrompts.length, 2); - for (const [index, prompt] of reviewerPrompts.entries()) { - assert.match(prompt, new RegExp(`candidate=${candidates[index]}`, "u")); - assert.match(prompt, new RegExp(`trusted-check candidate=${candidates[index]} summary=repository check pass`, "u")); + assert.equal(candidates.length, 6); + assert.equal(new Set(candidates).size, 3); + assert.equal(reviewerPrompts.length, 3); + for (const prompt of reviewerPrompts) { + const candidate = prompt.match(/^candidate=(cm1-sha256:[a-f0-9]{64})$/mu)?.[1]; + assert.ok(candidate); + assert.match(prompt, /artifact:sha256:[a-f0-9]{64}/u); } }); @@ -164,7 +202,7 @@ test("an over-limit post-maker candidate replays as a closed failure without a l writeFileSync(join(repo, "oversized-candidate.bin"), Buffer.alloc(16_777_217)); const final = { schema: "burnlist.agent-final@1", runId: values.run, nodeId: values.node, attempt: Number(values.attempt), claimId: values.claim, invocationId: values.invocation, assignmentId: values.assignment, recipeRevision: values.recipe, - policyRevision: values.policy, inputCandidate: values.candidate, outcome: "complete", summary: "maker complete" }; + policyRevision: values.policy, inputCandidate: values.candidate, outcome: "complete", summary: "maker complete", findings: [], resolvedFindingIds: [] }; return { cancel: () => true, completion: Promise.resolve({ outcome: "completed", events: [{ type: "item.completed", item: { type: "agent_message", text: JSON.stringify(final) } }] }) }; }; @@ -176,3 +214,46 @@ test("an over-limit post-maker candidate replays as a closed failure without a l assert.equal(replayed.projection.leaseHeld, false); assert.match(replayed.execution.system.summary, /too large/u); }); + +test("a settled managed cancellation resolves its claim, pauses, and resumes with a fresh attempt", async (t) => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-managed-pause-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")), store = runStore(repo); + await createProductionRun({ repoRoot: repo, store, itemRef: fixtureItemRef, runId: fixtureRunId }); + let started = 0, resolveCancelled; + const startAgent = () => { + started += 1; + return { + cancel() { + resolveCancelled?.({ outcome: "cancelled", events: [] }); + return true; + }, + completion: new Promise((resolve) => { resolveCancelled = resolve; }), + }; + }; + const first = createStoredProductionRunRunner({ repoRoot: repo, store, runId: fixtureRunId, startAgent }); + const firstRun = first.run(); + while (started !== 1) await new Promise((resolve) => setImmediate(resolve)); + first.requestPause(); + const paused = await firstRun; + assert.equal(paused.projection.state, "paused"); + assert.equal(paused.projection.leaseHeld, false); + assert.equal(paused.execution.externalClaim, null); + assert.equal(paused.execution.attempts.start, 1); + const firstClaim = paused.journal.find((record) => record.value.type === "external-claim-bound").value.payload.claim; + assert.equal(paused.journal.filter((record) => record.value.type === "external-claim-resolved").length, 1); + + // This new runner is the restart boundary: it must obtain a new lease and + // claim rather than continue the settled cancelled invocation. + const resumed = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId: fixtureRunId, startAgent }); + const resumedRun = resumed.run(); + while (started !== 2) await new Promise((resolve) => setImmediate(resolve)); + resumed.requestPause(); + const pausedAgain = await resumedRun; + const claims = pausedAgain.journal.filter((record) => record.value.type === "external-claim-bound").map((record) => record.value.payload.claim); + assert.deepEqual(claims.map((claim) => claim.attempt), [1, 2]); + assert.notEqual(claims[1].claimId, firstClaim.claimId); + assert.equal(pausedAgain.execution.attempts.start, 2); + assert.equal(pausedAgain.projection.state, "paused"); + assert.equal(pausedAgain.projection.leaseHeld, false); +}); diff --git a/src/loops/run/budgets.mjs b/src/loops/run/budgets.mjs index 4e223867..a0dd3322 100644 --- a/src/loops/run/budgets.mjs +++ b/src/loops/run/budgets.mjs @@ -4,13 +4,22 @@ const fail = (message) => { throw Object.assign(new Error(`Run budgets: ${messag export function validateBudget(value) { if (!exact(value) || !keys.every((key) => Number.isSafeInteger(value[key]) && value[key] > 0)) fail("invalid limits"); return Object.freeze({ ...value }); } export function foldBudgets({ records, graph }) { const budget = validateBudget(graph.budget), nodes = new Map(graph.nodes.map((node) => [node.id, node])), counters = { rounds: 0, agentRuns: 0, checkRuns: 0, transitions: 0, outputBytes: 0 }; - const visits = {}; let elapsedMilliseconds = 0, previous = records[0]?.value?.at; + const visits = {}, managedAttempts = new Set(records.filter((record) => record.value.type === "external-claim-bound") + .map((record) => `${record.value.payload.claim?.nodeId}\0${record.value.payload.claim?.attempt}`)); let elapsedMilliseconds = 0, previous = records[0]?.value?.at; for (const record of records) { if (!Number.isSafeInteger(record.value.at) || record.value.at < previous) fail("clock regressed"); elapsedMilliseconds += record.value.at - previous; previous = record.value.at; const { type, payload } = record.value; - if (type === "node-started") { const node = nodes.get(payload.nodeId); if (node.kind === "agent") { counters.agentRuns += 1; if (node.mode === "task") counters.rounds += 1; } else if (node.kind === "check") counters.checkRuns += 1; } + // A managed host claim is the agent-start boundary. It replaces (rather + // than supplements) node-started, so each accepted attempt is counted + // exactly once regardless of its execution transport. + if (type === "node-started" || type === "external-claim-bound") { + if (type === "node-started" && managedAttempts.has(`${payload.nodeId}\0${payload.attempt}`)) continue; + const node = nodes.get(type === "node-started" ? payload.nodeId : payload.claim?.nodeId); + if (node?.kind === "agent") { counters.agentRuns += 1; if (node.mode === "task") counters.rounds += 1; } + else if (type === "node-started" && node?.kind === "check") counters.checkRuns += 1; + } if (type === "edge-taken") { counters.transitions += 1; const key = `${payload.from}\0${payload.on}`, edge = graph.edges.find((item) => item.from === payload.from && item.on === payload.on); visits[key] = (visits[key] ?? 0) + 1; if (edge?.maxVisits !== null && visits[key] > edge.maxVisits) fail("edge visit exceeds limit"); } - if (type === "invocation-result") counters.outputBytes += payload.outputBytes; + if (type === "invocation-result" || type === "external-report-accepted") counters.outputBytes += payload.outputBytes; } if (counters.rounds > budget.maxRounds || counters.agentRuns > budget.maxAgentRuns || counters.checkRuns > budget.maxCheckRuns || counters.transitions > budget.maxTransitions || counters.outputBytes > budget.maxOutputBytes) fail("inclusive limit exceeded"); return Object.freeze({ counters: Object.freeze(counters), visits: Object.freeze(visits), elapsedMilliseconds, timeExceeded: elapsedMilliseconds > budget.maxMinutes * 60_000, journal: Object.freeze({ maximum: MAX_JOURNAL_RECORDS, used: records.length, remaining: MAX_JOURNAL_RECORDS - records.length }) }); diff --git a/src/loops/run/budgets.test.mjs b/src/loops/run/budgets.test.mjs index ab703e09..d01f48be 100644 --- a/src/loops/run/budgets.test.mjs +++ b/src/loops/run/budgets.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { budgetReason, foldBudgets } from "./budgets.mjs"; import { createJournalRecord } from "./run-journal.mjs"; import { created, testGraph } from "./m2-test-fixtures.mjs"; +import { createHostClaim } from "./run-claim.mjs"; const record = (sequence, prior, type, payload, at = sequence) => createJournalRecord({ sequence, prevDigest: prior?.digest ?? null, at, type, payload }); test("fold enforces inclusive counters, retries, visits, output, and time", () => { @@ -13,3 +14,30 @@ test("fold enforces inclusive counters, retries, visits, output, and time", () = for (let index = 1; index <= 4; index += 1) visits.push(record(index + 1, visits.at(-1), "edge-taken", { from: "review", on: "reject", to: "implement" })); assert.throws(() => foldBudgets({ records: visits, graph: testGraph }), /visit exceeds/u); }); + +test("managed claim and report boundaries consume agent, round, and output budgets once", () => { + const one = record(1, null, "run-created", created(), 0); + const hostClaim = createHostClaim({ runId: created().runId, nodeId: "implement", attempt: 1, + assignmentId: `as1-sha256:${"a".repeat(64)}`, inputCandidate: `cm1-sha256:${"b".repeat(64)}`, + executionDigest: `sha256:${"c".repeat(64)}`, expiresAt: 10_000 }); + const bound = { claim: hostClaim, envelopeDigest: hostClaim.executionDigest, invocationId: `iv1-sha256:${"d".repeat(64)}` }; + const claim = record(2, one, "external-claim-bound", bound); + const report = record(3, claim, "external-report-accepted", { claimId: hostClaim.claimId, reportDigest: `sha256:${"e".repeat(64)}`, + invocationId: bound.invocationId, kind: "complete", summary: "ok", outputBytes: 7, candidateId: null }); + const managed = [one, claim, report]; + const exact = { ...testGraph, budget: { ...testGraph.budget, maxAgentRuns: 1, maxRounds: 1, maxOutputBytes: 7 } }; + const folded = foldBudgets({ records: managed, graph: exact }); + assert.deepEqual(folded.counters, { rounds: 1, agentRuns: 1, checkRuns: 0, transitions: 0, outputBytes: 7 }); + const directStart = record(2, one, "node-started", { nodeId: "implement", attempt: 1 }); + const mixed = [one, directStart, record(3, directStart, "external-claim-bound", bound)]; + assert.equal(foldBudgets({ records: mixed, graph: exact }).counters.agentRuns, 1, "transport boundary cannot double count an attempt"); + assert.equal(budgetReason({ folded, graph: exact, node: exact.nodes.find((node) => node.id === "implement") }), "agent-runs"); + const nextClaim = createHostClaim({ runId: hostClaim.runId, nodeId: hostClaim.nodeId, attempt: 2, + assignmentId: hostClaim.assignmentId, inputCandidate: hostClaim.inputCandidate, + executionDigest: `sha256:${"f".repeat(64)}`, expiresAt: 20_000 }); + const secondClaim = record(4, report, "external-claim-bound", { claim: nextClaim, envelopeDigest: nextClaim.executionDigest, invocationId: `iv1-sha256:${"1".repeat(64)}` }); + const secondReport = record(4, report, "external-report-accepted", { claimId: hostClaim.claimId, reportDigest: `sha256:${"2".repeat(64)}`, + invocationId: bound.invocationId, kind: "complete", summary: "ok", outputBytes: 1, candidateId: null }); + assert.throws(() => foldBudgets({ records: [...managed, secondClaim], graph: exact }), /inclusive limit exceeded/u); + assert.throws(() => foldBudgets({ records: [...managed, secondReport], graph: exact }), /inclusive limit exceeded/u); +}); diff --git a/src/loops/run/candidate.mjs b/src/loops/run/candidate.mjs index dd19b58e..ab6e6811 100644 --- a/src/loops/run/candidate.mjs +++ b/src/loops/run/candidate.mjs @@ -10,7 +10,7 @@ const RUNTIME_ROOTS = new Set([".local", "node_modules"]); const fail = (message) => { throw Object.assign(new Error(`Loop candidate: ${message}`), { code: "ECANDIDATE" }); }; const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); -function repositoryPaths(root) { +function repositoryPaths(root, excludedPaths) { let bytes; try { bytes = execFileSync("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { @@ -21,7 +21,7 @@ function repositoryPaths(root) { if (paths.pop() !== "" || new Set(paths).size !== paths.length || paths.some((path) => !path || path.startsWith("/") || path.split("/").some((part) => part === ".."))) fail("worktree path list is invalid or exceeds bounds"); - const included = paths.filter((path) => !RUNTIME_ROOTS.has(path.split("/", 1)[0])).sort(); + const included = paths.filter((path) => !RUNTIME_ROOTS.has(path.split("/", 1)[0]) && !excludedPaths.has(path)).sort(); if (included.length > MAX_FILES) fail("worktree path list exceeds bounds"); return included; } @@ -49,18 +49,21 @@ function fileRecord(root, relativePath) { return `${relativePath}\tfile\t${bytes.length}\t${sha256(bytes)}`; } -/** A bounded Git worktree manifest. This is boundary detection, not hostile-writer isolation. */ -export function deriveCandidate({ repoRoot }) { +/** A bounded Git worktree manifest. This detects drift at caller-defined boundaries; it is not hostile-writer isolation. */ +export function deriveCandidate({ repoRoot, excludedPaths = [] }) { const root = resolve(repoRoot), rootBefore = lstatSync(root); if (!rootBefore.isDirectory() || rootBefore.isSymbolicLink()) fail("repository root is unsafe"); - const paths = repositoryPaths(root), records = []; let total = 0; + if (!Array.isArray(excludedPaths) || excludedPaths.length > 16 || excludedPaths.some((path) => typeof path !== "string" || !path || path.startsWith("/") || path.split("/").some((part) => !part || part === "." || part === ".."))) fail("candidate exclusion is invalid"); + const exclusions = new Set(excludedPaths); + if (exclusions.size !== excludedPaths.length) fail("candidate exclusion is invalid"); + const paths = repositoryPaths(root, exclusions), records = []; let total = 0; for (const path of paths) { const record = fileRecord(root, path), fields = record.split("\t"); total += Number(fields[2]); if (total > MAX_TOTAL_BYTES) fail("candidate manifest exceeds bounds"); records.push(record); } - if (repositoryPaths(root).join("\0") !== paths.join("\0")) fail("worktree paths changed while capturing candidate"); + if (repositoryPaths(root, exclusions).join("\0") !== paths.join("\0")) fail("worktree paths changed while capturing candidate"); const rootAfter = lstatSync(root); if (!rootAfter.isDirectory() || rootAfter.isSymbolicLink() || rootBefore.dev !== rootAfter.dev || rootBefore.ino !== rootAfter.ino) fail("repository root changed while capturing candidate"); const manifest = `candidate-manifest@2\n${records.join("\n")}\n`; diff --git a/src/loops/run/controller.mjs b/src/loops/run/controller.mjs index 479488d3..88c9043a 100644 --- a/src/loops/run/controller.mjs +++ b/src/loops/run/controller.mjs @@ -1,12 +1,14 @@ import { presentRun } from "./read-projection.mjs"; import { isRunRef } from "./run-ref.mjs"; +import { prepareHostClaim } from "./host-execution.mjs"; +import { deriveCandidate } from "./candidate.mjs"; const fail = (message, code = "ELOOP_CONTROL") => { throw Object.assign(new Error(`Loop control: ${message}`), { code }); }; const stable = (value) => `${JSON.stringify(value)}\n`; /** Small foreground-only control boundary. It owns no daemon or recovery policy. */ -export function createLoopController({ store, runnerFor }) { - if (!store?.read || !store?.list || !store?.acquireLease || !store?.terminalize) fail("invalid controller input"); +export function createLoopController({ store, runnerFor, repoRoot = null }) { + if (!store?.read || !store?.list || !store?.acquireLease || !store?.terminalize || !store?.bindExternalClaim || !store?.readExternalClaim || !store?.abandonExternalClaim || !store?.acceptExternalReport || !store?.resolveClaimRef) fail("invalid controller input"); const check = (runId) => { if (!isRunRef(runId)) fail("invalid RunRef"); return runId; }; const read = (runId) => store.read(check(runId)); const inspect = (runId) => Object.freeze(presentRun(read(runId))); @@ -31,6 +33,38 @@ export function createLoopController({ store, runnerFor }) { const lease = idleLease(runId); return presentRun(store.terminalize(runId, lease, "cancelled", "control")); } + /** Claiming is controller-owned: the journal lease serializes contenders and exact retries reread the same envelope. */ + function claim(runId) { + check(runId); const current = read(runId), active = store.readExternalClaim(runId, current); + if (active) fail("Run agent node is already claimed", "ECLAIMED"); + const lease = idleLease(runId); + try { + const prepared = prepareHostClaim({ repoRoot, replay: read(runId), authority: store.readAuthority(runId) }); + return store.bindExternalClaim(runId, lease, prepared); + } + catch (error) { + // A failed preparation has not started a host. Releasing this private + // controller lease makes the normal foreground owner available again. + try { if (read(runId).execution.lease) store.releaseLease(runId, lease); } catch {} + throw error; + } + } + function report(claimRef, bytes) { + const runId = store.resolveClaimRef(claimRef), current = read(runId); + return presentRun(store.acceptExternalReport(runId, current.execution.lease, bytes, + () => deriveCandidate({ repoRoot }))); + } + function readClaim(runId) { check(runId); return store.readExternalClaim(runId); } + function abandonClaim(runId, abandonment) { + check(runId); const current = read(runId); + if (current.execution.terminal) return inspect(runId); + if (current.execution.externalClaim) { + if (!current.execution.lease) fail("external claim owner is not fenced", "ELEASED"); + return presentRun(store.abandonExternalClaim(runId, current.execution.lease, abandonment)); + } + const lease = idleLease(runId); + return presentRun(store.abandonExternalClaim(runId, lease, abandonment)); + } async function run(runId) { check(runId); if (typeof runnerFor !== "function") fail("foreground runner is unavailable", "ERUNNER_UNAVAILABLE"); const runner = runnerFor(runId); if (!runner?.run) fail("foreground runner is unavailable", "ERUNNER_UNAVAILABLE"); @@ -45,5 +79,5 @@ export function createLoopController({ store, runnerFor }) { const lease = idleLease(runId); return presentRun(store.terminalize(runId, lease, "lost", "reconciled lost invocation")); } - return Object.freeze({ list, inspect, status, pause, stop, run, reconcile, render: stable }); + return Object.freeze({ list, inspect, status, pause, stop, run, reconcile, claim, report, readClaim, abandonClaim, render: stable }); } diff --git a/src/loops/run/host-execution.mjs b/src/loops/run/host-execution.mjs new file mode 100644 index 00000000..3f42254f --- /dev/null +++ b/src/loops/run/host-execution.mjs @@ -0,0 +1,67 @@ +import { randomBytes } from "node:crypto"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { createDispatchAuthority, createInvocationInput } from "../contracts/agent-result.mjs"; +import { createHostExecutionEnvelope } from "../contracts/host-execution.mjs"; +import { boundPolicyRevision, loadBoundPolicy } from "./run-artifacts.mjs"; +import { createHostClaim } from "./run-claim.mjs"; +import { deriveCandidate } from "./candidate.mjs"; + +const fail = (message) => { throw Object.assign(new Error(`Host execution: ${message}`), { code: "EHOST_EXECUTION" }); }; + +export function prepareHostClaim({ repoRoot, replay, authority, now = Date.now(), random = randomBytes }) { + const node = replay?.execution?.node; + if (replay.execution.terminal || replay.execution.started || node?.kind !== "agent") fail("current node is not an unstarted agent"); + const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")); + const policy = loadBoundPolicy(Buffer.from(authority.policy, "base64")); + const instruction = frozen.instructions.find((item) => item.id === node.instructions); + if (!instruction) fail("frozen node instructions are unavailable"); + const candidate = deriveCandidate({ repoRoot }), attempt = replay.execution.attempt + 1; + // A reviewer is evidence about the maker's folded candidate, not about a + // convenient later workspace snapshot. Refuse to dispatch it if anything + // has changed since the candidate was bound. + if (node.mode === "review" && (!replay.execution.candidate || replay.execution.candidate.id !== candidate.id)) + fail("candidate drift before review claim"); + const reviewerEvidence = node.mode === "review" ? (() => { + const evidence = Object.entries(replay.execution.evidence ?? {}) + .filter(([nodeId, value]) => frozen.ir.nodes.find((item) => item.id === nodeId)?.kind === "check" + && value?.kind === "pass" && value.candidateId === candidate.id && value.cycle === replay.execution.cycle) + .sort(([left], [right]) => left.localeCompare(right)); + if (!evidence.length || replay.execution.latest.check?.candidateId !== candidate.id) + fail("trusted check evidence is unavailable for review claim"); + return evidence.map(([nodeId, value]) => `artifact:${rawSha256(Buffer.from(JSON.stringify({ + schema: "burnlist-loop-review-evidence@1", nodeId, kind: value.kind, candidateId: value.candidateId, cycle: value.cycle, + }), "utf8"))}`); + })() : []; + const invocationId = prefixed("iv1-sha256:", "host-invocation-v1", [random(32)]), issuedAt = now, expiresAt = issuedAt + 60 * 60 * 1000; + const common = { + runId: replay.runId, nodeId: node.id, attempt, assignmentId: authority.assignmentId, invocationId, + recipeRevision: frozen.revisions.executable, policyRevision: boundPolicyRevision(policy.policy), + inputCandidate: candidate.id, + }; + const claimId = createHostClaim({ ...common, executionDigest: `sha256:${"0".repeat(64)}`, expiresAt }).claimId; + const invocationInput = createInvocationInput({ + schema: "burnlist-loop-invocation-input@1", ...common, claimId, itemRevision: authority.itemRevision, + ...(node.execution ? { execution: node.execution, intelligence: node.intelligence } : {}), + mode: node.mode, role: node.role, authority: node.authority, + legalOutcomes: node.mode === "task" ? ["complete"] : ["approve", "reject", "escalate"], + requires: [...(node.requires ?? [])].sort(), + openFindings: node.mode === "review" + ? [...replay.execution.openFindings.values()].sort((left, right) => left.id.localeCompare(right.id)) + : [], + instructionDigest: rawSha256(Buffer.from(instruction.base64, "base64")), instructionBytes: instruction.base64, + itemText: Buffer.from(authority.itemText).toString("base64"), + candidateContext: Buffer.from(candidate.context).toString("base64"), reviewerEvidence, + }); + const dispatchAuthority = createDispatchAuthority({ + schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...common, claimId, + itemRevision: authority.itemRevision, inputSchema: invocationInput.value.schema, + inputDigest: invocationInput.digest, inputByteLength: invocationInput.bytes.length, + }); + const envelope = createHostExecutionEnvelope({ + schema: "burnlist-loop-host-execution@1", ...common, claimId, issuedAt, expiresAt, + invocationInput: invocationInput.bytes.toString("base64"), dispatchAuthority: dispatchAuthority.bytes.toString("base64"), + }); + const claim = createHostClaim({ ...common, claimId, executionDigest: envelope.digest, expiresAt }); + return Object.freeze({ claim, envelope: envelope.bytes }); +} diff --git a/src/loops/run/m2-test-fixtures.mjs b/src/loops/run/m2-test-fixtures.mjs index 5fff7c6c..7315ac77 100644 --- a/src/loops/run/m2-test-fixtures.mjs +++ b/src/loops/run/m2-test-fixtures.mjs @@ -1,8 +1,8 @@ export const testRunId = "run:01arz3ndektsv4rrffq69g5fav"; export const testGraph = Object.freeze({ schema: "burnlist-loop-ir@1", compiler: "burnlist-loop-compiler@1", id: "review", declaredVersion: "0.1.0", entry: "implement", budget: { maxRounds: 3, maxMinutes: 60, maxAgentRuns: 6, maxCheckRuns: 3, maxTransitions: 16, maxOutputBytes: 262144 }, nodes: [ - { kind: "agent", id: "implement", mode: "task", role: "maker", route: "implementation.standard", authority: "write", instructions: "implement", independentFrom: null, requires: [] }, + { kind: "agent", id: "implement", mode: "task", execution: "managed", intelligence: "standard", role: "maker", route: "implementation.standard", authority: "write", instructions: "implement", independentFrom: null, requires: [] }, { kind: "terminal", id: "completed", state: "converged" }, { kind: "gate", id: "converged", gateKind: "convergence", requires: ["verify", "review"] }, { kind: "terminal", id: "exhausted", state: "budget-exhausted" }, { kind: "terminal", id: "failed", state: "failed" }, { kind: "terminal", id: "needs-human", state: "needs-human" }, - { kind: "agent", id: "review", mode: "review", role: "reviewer", route: "review.strong", authority: "read", instructions: "review", independentFrom: "implement", requires: ["fresh-session:enforced", "filesystem-write-deny:supervised"] }, { kind: "terminal", id: "stopped", state: "stopped" }, { kind: "check", id: "verify", capability: "repo-verify" }, + { kind: "agent", id: "review", mode: "review", execution: "managed", intelligence: "strong", role: "reviewer", route: "review.strong", authority: "read", instructions: "review", independentFrom: "implement", requires: ["fresh-session:enforced", "filesystem-write-deny:supervised"] }, { kind: "terminal", id: "stopped", state: "stopped" }, { kind: "check", id: "verify", capability: "repo-verify" }, ], failurePolicy: { error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "exhausted" }, edges: [ { from: "implement", on: "complete", to: "verify", maxVisits: null }, { from: "converged", on: "pass", to: "completed", maxVisits: null }, { from: "converged", on: "fail", to: "needs-human", maxVisits: null }, { from: "review", on: "approve", to: "converged", maxVisits: null }, { from: "review", on: "reject", to: "implement", maxVisits: 3 }, { from: "review", on: "escalate", to: "needs-human", maxVisits: null }, { from: "verify", on: "pass", to: "review", maxVisits: null }, { from: "verify", on: "fail", to: "implement", maxVisits: 3 }, ], instructions: [{ id: "implement", digest: "sha256:8d1db5c7cbc11075fccc565180374e1929a8cf22683cbca3dde9b77ef667c0a8", byteLength: 114 }, { id: "review", digest: "sha256:53ca65be9491688119b5cbf2443d0b9aff003764db9e91657ec54dbae12d76aa", byteLength: 119 }] }); diff --git a/src/loops/run/production-runner.mjs b/src/loops/run/production-runner.mjs new file mode 100644 index 00000000..5cfd5156 --- /dev/null +++ b/src/loops/run/production-runner.mjs @@ -0,0 +1,123 @@ +import { createNormalizedInvocation } from "../adapters/normalized-invocation.mjs"; +import { createHostExecutionReport, validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; +import { boundPolicyRevision, loadBoundPolicy } from "./run-artifacts.mjs"; +import { createRunRunner } from "./runner.mjs"; +import { deriveCandidate } from "./candidate.mjs"; +import { ownerClaimId } from "./run-claim.mjs"; +import { prepareHostClaim } from "./host-execution.mjs"; +import { loadFrozenRecipe } from "../dsl/frozen.mjs"; + +const fail = (message) => { + throw Object.assign(new Error(`Loop Run binder: ${message}`), { code: "ELOOP_RUN_BINDING" }); +}; + +export function createBoundNormalizedInvocationImpl({ repoRoot, replay, contextFor, candidateForBoundary = null, + startAgent, runCheck, agentTimeoutMs = 0 }) { + if (typeof repoRoot !== "string" || !replay?.projection?.assignmentId || !replay?.frozenRecipe?.ir + || typeof replay.itemText !== "string" || !replay.itemText + || !Buffer.isBuffer(replay.policyBytes) || typeof contextFor !== "function") fail("invalid production invocation input"); + const policy = loadBoundPolicy(replay.policyBytes).policy; + const route = (name) => policy.routes.find((entry) => entry.route === name); + const implementation = route("implementation.standard"), review = route("review.strong"); + if (!implementation || !review) fail("frozen Stage One routes are unavailable"); + const nodes = new Map(replay.frozenRecipe.ir.nodes.map((node) => [node.id, node])); + return createNormalizedInvocation({ repoRoot, nodes, + routes: { implementation: { profile: implementation.profile }, review: { profile: review.profile } }, + bindingFor(invocation, node) { + const context = contextFor(invocation, node), instruction = replay.frozenRecipe.instructions + .find((item) => item.id === node.instructions); + if (!context || node.kind === "agent" && !instruction) fail("frozen invocation context is unavailable"); + return { claimId: context.claimId, assignmentId: replay.projection.assignmentId, + recipeRevision: replay.frozenRecipe.revisions.executable, policyRevision: boundPolicyRevision(policy), + inputCandidate: context.inputCandidate, instructionBytes: instruction + ? Buffer.from(instruction.base64, "base64").toString("utf8") : "Run the frozen trusted capability.\n", + itemText: replay.itemText, candidateContext: context.candidateContext, + reviewerEvidence: context.reviewerEvidence ?? [] }; + }, candidateForBoundary, startAgent, runCheck, agentTimeoutMs }); +} + +export function createProductionRunRunnerImpl({ repoRoot, store, runId, authority, contextFor, + startAgent, runCheck, agentTimeoutMs = 0, binding }) { + if (authority?.schema === "burnlist-loop-m12-run-authority@1") authority = binding.unseal(authority); + if (!store?.replay || !authority?.assignmentId || !Buffer.isBuffer(authority.frozenRecipeBytes) + || !Buffer.isBuffer(authority.policyBytes)) fail("invalid production runner authority"); + const frozenRecipe = loadFrozenRecipe(authority.frozenRecipeBytes); + const replay = { projection: { assignmentId: authority.assignmentId }, frozenRecipe, + policyBytes: authority.policyBytes, itemText: authority.itemText }; + const liveContext = (invocation, node) => { + const execution = store.replay(runId).execution; + const candidate = execution.candidate ?? deriveCandidate({ repoRoot }); + const check = frozenRecipe.ir.nodes.filter((item) => item.kind === "check") + .map((item) => execution.evidence[item.id]) + .find((item) => item?.kind === "pass" && item.candidateId === candidate.id && item.cycle === execution.cycle); + const reviewerEvidence = node.mode === "review" + ? check?.kind === "pass" && check.candidateId === candidate.id && execution.latest.check?.candidateId === candidate.id + ? [`trusted-check candidate=${candidate.id} summary=${execution.latest.check.summary}`] : [] + : []; + return { claimId: ownerClaimId({ runId: invocation.runId, nodeId: invocation.nodeId, attempt: invocation.attempt, + assignmentId: authority.assignmentId, inputCandidate: candidate.id }), inputCandidate: candidate.id, + candidateContext: candidate.context, reviewerEvidence }; + }; + const dispatch = createBoundNormalizedInvocationImpl({ repoRoot, replay, contextFor: contextFor ?? liveContext, + candidateForBoundary: () => deriveCandidate({ repoRoot }), startAgent, runCheck, agentTimeoutMs }); + const launchReplay = () => ({ ...replay, projection: { ...replay.projection, + itemRef: authority.itemRef, itemRevision: authority.itemRevision }, + boundPolicy: loadBoundPolicy(authority.policyBytes).policy }); + const invoke = async (invocation) => { + const captured = binding.capture({ repoRoot, replay: launchReplay() }); + binding.recheck(captured); const held = binding.hold(captured); + try { binding.recheck(captured); return await dispatch(invocation); } + finally { binding.release(held); } + }; + const preparedReplay = () => ({ ...store.replay(runId), frozenRecipe, policyBytes: authority.policyBytes, + itemText: authority.itemText, projection: { ...store.replay(runId).projection, assignmentId: authority.assignmentId, + itemRef: authority.itemRef, itemRevision: authority.itemRevision } }); + let managedPauseRequested = false; + const executePreparedAgent = async ({ lease }) => { + let held = null; + try { + const captured = binding.capture({ repoRoot, replay: launchReplay() }); + binding.recheck(captured); held = binding.hold(captured); binding.recheck(captured); + const prepared = prepareHostClaim({ repoRoot, replay: preparedReplay(), authority: binding.seal(runId, authority) }); + const bound = store.bindExternalClaim(runId, lease, prepared); + const result = await dispatch.invokePrepared(bound.envelope); + const envelope = validateHostExecutionEnvelope(bound.envelope); + if (managedPauseRequested && result.kind === "cancelled") { + managedPauseRequested = false; + store.resolveExternalClaim(runId, lease, { claimId: envelope.value.claimId, invocationId: envelope.value.invocationId, reason: "paused" }); + return { released: false }; + } + if (!["complete", "approve", "reject", "escalate"].includes(result.kind)) { + const kind = result.kind === "cancelled" ? "cancelled" : result.kind === "timeout" ? "timeout" : result.kind === "lost" ? "lost" : "error"; + store.terminalize(runId, lease, kind, result.summary); + return; + } + const report = createHostExecutionReport({ schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", runId: envelope.value.runId, nodeId: envelope.value.nodeId, attempt: envelope.value.attempt, + claimId: envelope.value.claimId, assignmentId: envelope.value.assignmentId, invocationId: envelope.value.invocationId, + recipeRevision: envelope.value.recipeRevision, policyRevision: envelope.value.policyRevision, + inputCandidate: envelope.value.inputCandidate, outcome: result.kind, + findings: result.findings ?? [], resolvedFindingIds: result.resolvedFindingIds ?? [], + }, telemetry: result.telemetry ?? null }, { + envelope, mode: preparedReplay().execution.node.mode, + openFindings: preparedReplay().execution.node.mode === "review" + ? preparedReplay().execution.openFindings : new Map(), + }); + store.acceptExternalReport(runId, lease, report.bytes, () => deriveCandidate({ repoRoot })); + return { released: true }; + } catch (error) { + const current = store.replay(runId); + if (!current.execution.terminal && current.execution.lease) + store.terminalize(runId, lease, "error", String(error?.message ?? "managed claim failed")); + else throw error; + } finally { if (held) binding.release(held); } + }; + Object.defineProperty(executePreparedAgent, "cancel", { value: () => { + managedPauseRequested = true; return dispatch.cancel?.() === true; + }, enumerable: false }); + let preparedClaimsAvailable = false; + try { store.readAuthority?.(runId); preparedClaimsAvailable = true; } catch { /* legacy in-memory fixture */ } + return createRunRunner({ store, runId, invoke, executePreparedAgent: preparedClaimsAvailable ? executePreparedAgent : null, bindCandidate() { + const candidate = deriveCandidate({ repoRoot }); return { candidateId: candidate.id, candidateContext: candidate.context }; + } }); +} diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index f76ec38f..2d4a7795 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -7,6 +7,7 @@ import { locateItemSpan, validateAssignedItem } from "../assignment/item-metadat import { assignmentStore } from "../assignment/store.mjs"; import { currentRunAuthority } from "./current-authority.mjs"; import { runStore } from "./run-store.mjs"; +import { projectRunActivity } from "../events/activity-projection.mjs"; const MAX_RUNS = 128; const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; @@ -19,6 +20,8 @@ function publicNode(node, routes = []) { ...common, role: node.role, authority: node.authority, + executionMode: node.execution, + intelligence: node.intelligence, execution: resolved ? { profileId: resolved.profileId, model: resolved.model, @@ -47,12 +50,16 @@ export function presentRun(replay) { const transitions = []; for (const record of records) { const { sequence, type, payload } = record.value; - if (type === "invocation-result") latestResult = { + if (type === "invocation-result" || type === "external-report-accepted") latestResult = { kind: payload.kind, summary: payload.summary, }; if (type === "edge-taken") transitions.push({ sequence, from: payload.from, outcome: payload.on, to: payload.to }); if (type === "state-changed") transitions.push({ sequence, from: payload.from, outcome: payload.cause, to: payload.to }); } + const declaredMode = replay.execution.node?.execution; + const executionMode = replay.execution.started + ? declaredMode === "managed" ? "managed" : declaredMode === "host" ? "host-reported" : "unavailable" + : "unavailable"; return Object.freeze({ schema: "burnlist-loop-read-projection@1", runId: replay.projection.runId, @@ -65,6 +72,15 @@ export function presentRun(replay) { currentNode: replay.projection.currentNode, attempt: replay.projection.attempt, cycle: replay.execution.cycle, + execution: { + mode: executionMode, + started: replay.execution.started, + usage: replay.execution.telemetry + && (replay.execution.telemetry.inputTokens !== null || replay.execution.telemetry.outputTokens !== null) + ? "reported" : "unavailable", + telemetry: replay.execution.telemetry, + }, + activity: projectRunActivity({ runId: replay.projection.runId, graph: replay.graph, journal: records }), latestResult, latestMaker: replay.projection.latestMaker, latestCheck: replay.projection.latestCheck, diff --git a/src/loops/run/run-claim.mjs b/src/loops/run/run-claim.mjs index 1dc2c7ed..3c06be04 100644 --- a/src/loops/run/run-claim.mjs +++ b/src/loops/run/run-claim.mjs @@ -1,9 +1,40 @@ -import { prefixed } from "../dsl/hash.mjs"; +import { prefixed, rawSha256 } from "../dsl/hash.mjs"; +import { parseBoundedObject } from "../contracts/contract.mjs"; import { RUN_ID, fail } from "./run-codec.mjs"; const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; +const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const HOST_CLAIM_KEYS = ["schema", "runId", "claimId", "nodeId", "attempt", "assignmentId", "inputCandidate", "executionDigest", "expiresAt"]; +const ABANDONMENT_KEYS = [...HOST_CLAIM_KEYS, "reason"]; +const ABANDONMENT_REASONS = new Set(["host-cancelled", "host-lost", "expired"]); +const MAX_EXPIRES_AT = 8_640_000_000_000_000; +const MAX_CLAIM_BYTES = 16_384; + +export const HOST_CLAIM_SCHEMA = "burnlist-loop-host-claim@1"; +export const HOST_CLAIM_ABANDONMENT_SCHEMA = "burnlist-loop-host-claim-abandonment@1"; +export const HOST_CLAIM_MAX_BYTES = MAX_CLAIM_BYTES; +export const HOST_CLAIM_ABANDONMENT_REASONS = Object.freeze(["host-cancelled", "host-lost", "expired"]); +export const HOST_CLAIM_MAX_EXPIRES_AT_MILLISECONDS = MAX_EXPIRES_AT; + +function exact(value, keys) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype && Object.keys(value).length === keys.length + && keys.every((key, index) => Object.keys(value)[index] === key); +} + +function validExpiry(value) { return Number.isSafeInteger(value) && value >= 0 && value <= MAX_EXPIRES_AT; } + +function claimFields(value, schema, keys) { + if (!exact(value, keys) || value.schema !== schema || !RUN_ID.test(value.runId) || !CLAIM.test(value.claimId) + || !SLUG.test(value.nodeId) || !Number.isInteger(value.attempt) || value.attempt < 1 || value.attempt > 100 + || !ASSIGNMENT.test(value.assignmentId) || !CANDIDATE.test(value.inputCandidate) || !DIGEST.test(value.executionDigest) + || !validExpiry(value.expiresAt) + || value.claimId !== ownerClaimId(value)) fail("invalid host claim"); + return value; +} export function ownerClaimId({ runId, nodeId, attempt, assignmentId, inputCandidate }) { if (!RUN_ID.test(runId) || !SLUG.test(nodeId) || !Number.isInteger(attempt) || attempt < 1 || attempt > 100 @@ -17,3 +48,59 @@ export function createOwnerClaim(value) { return Object.freeze({ schema: "burnlist-loop-owner-claim@1", runId: value.runId, claimId, nodeId: value.nodeId, attempt: value.attempt, assignmentId: value.assignmentId, inputCandidate: value.inputCandidate }); } + +/** A host-issued lease. `expiresAt` is supplied by the controller; this module never reads a clock. */ +export function createHostClaim(value) { + const claimId = ownerClaimId(value); + if (value.claimId !== undefined && value.claimId !== claimId) fail("fabricated host claim id"); + const claim = { schema: HOST_CLAIM_SCHEMA, runId: value.runId, claimId, nodeId: value.nodeId, + attempt: value.attempt, assignmentId: value.assignmentId, inputCandidate: value.inputCandidate, + executionDigest: value.executionDigest, expiresAt: value.expiresAt }; + return Object.freeze(claimFields(claim, HOST_CLAIM_SCHEMA, HOST_CLAIM_KEYS)); +} + +export function validateHostClaim(value) { + const claim = claimFields(value, HOST_CLAIM_SCHEMA, HOST_CLAIM_KEYS); + return Object.freeze({ schema: claim.schema, runId: claim.runId, claimId: claim.claimId, nodeId: claim.nodeId, + attempt: claim.attempt, assignmentId: claim.assignmentId, inputCandidate: claim.inputCandidate, + executionDigest: claim.executionDigest, expiresAt: claim.expiresAt }); +} + +export function hostClaimExpired(value, now) { + if (!validExpiry(now)) fail("invalid host claim clock"); + return now >= validateHostClaim(value).expiresAt; +} + +export function createHostClaimAbandonment(value) { + const claim = createHostClaim(value); + const abandonment = { ...claim, schema: HOST_CLAIM_ABANDONMENT_SCHEMA, reason: value.reason }; + return validateHostClaimAbandonment(abandonment); +} + +export function validateHostClaimAbandonment(value) { + const abandonment = claimFields(value, HOST_CLAIM_ABANDONMENT_SCHEMA, ABANDONMENT_KEYS); + if (!ABANDONMENT_REASONS.has(abandonment.reason)) fail("invalid host claim abandonment reason"); + return Object.freeze({ schema: abandonment.schema, runId: abandonment.runId, claimId: abandonment.claimId, + nodeId: abandonment.nodeId, attempt: abandonment.attempt, assignmentId: abandonment.assignmentId, + inputCandidate: abandonment.inputCandidate, executionDigest: abandonment.executionDigest, + expiresAt: abandonment.expiresAt, reason: abandonment.reason }); +} + +function canonical(value) { + const claim = validateHostClaim(value); + return Buffer.from(`${JSON.stringify(claim)}\n`, "utf8"); +} + +export function createHostClaimDocument(value) { + const claim = createHostClaim(value), bytes = canonical(claim); + if (bytes.length > MAX_CLAIM_BYTES) fail("host claim exceeds bounds"); + return Object.freeze({ value: claim, bytes, digest: rawSha256(bytes) }); +} + +export function parseHostClaim(bytes) { + const raw = Buffer.from(bytes); + const value = parseBoundedObject(raw, { maximumBytes: MAX_CLAIM_BYTES, maximumDepth: 1, label: "host claim" }); + const built = createHostClaimDocument(value); + if (!built.bytes.equals(raw)) fail("host claim is not canonical"); + return built; +} diff --git a/src/loops/run/run-claim.test.mjs b/src/loops/run/run-claim.test.mjs new file mode 100644 index 00000000..3b0f2dc6 --- /dev/null +++ b/src/loops/run/run-claim.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHostClaim, createHostClaimAbandonment, createHostClaimDocument, createOwnerClaim, hostClaimExpired, + HOST_CLAIM_ABANDONMENT_REASONS, parseHostClaim, validateHostClaim, validateHostClaimAbandonment } from "./run-claim.mjs"; + +const identity = { + runId: "run:01j0abcdefghjkmnpqrstvwxyz", nodeId: "implement", attempt: 1, + assignmentId: `as1-sha256:${"a".repeat(64)}`, inputCandidate: `cm1-sha256:${"b".repeat(64)}`, + executionDigest: `sha256:${"c".repeat(64)}`, +}; + +test("host claim derives the compatible owner identity and has a caller-provided expiry", () => { + const claim = createHostClaim({ ...identity, expiresAt: 100 }); + assert.equal(claim.claimId, createOwnerClaim(identity).claimId); + assert.deepEqual(validateHostClaim(claim), claim); + assert.equal(hostClaimExpired(claim, 99), false); + assert.equal(hostClaimExpired(claim, 100), true); + assert.throws(() => hostClaimExpired(claim, -1), /invalid host claim clock/u); + const document = createHostClaimDocument({ ...identity, expiresAt: 100 }); + assert.equal(parseHostClaim(document.bytes).digest, document.digest); + assert.throws(() => parseHostClaim(Buffer.concat([document.bytes, Buffer.from(" ")])), /canonical/u); +}); + +test("host claims fail closed for fabricated identity, unknown fields, and invalid expiry", () => { + const claim = createHostClaim({ ...identity, expiresAt: 100 }); + assert.throws(() => createHostClaim({ ...identity, claimId: `cl1-sha256:${"c".repeat(64)}`, expiresAt: 100 }), /fabricated/u); + assert.throws(() => validateHostClaim({ ...claim, extra: true }), /invalid host claim/u); + assert.throws(() => createHostClaim({ ...identity, expiresAt: 1.5 }), /invalid host claim/u); + assert.throws(() => createHostClaim({ ...identity, executionDigest: `sha256:${"z".repeat(64)}`, expiresAt: 100 }), /invalid host claim/u); +}); + +test("host abandonment is identity-bound and has closed reasons", () => { + const abandonment = createHostClaimAbandonment({ ...identity, expiresAt: 100, reason: "host-lost" }); + assert.deepEqual(validateHostClaimAbandonment(abandonment), abandonment); + assert.deepEqual(HOST_CLAIM_ABANDONMENT_REASONS, ["host-cancelled", "host-lost", "expired"]); + assert.throws(() => createHostClaimAbandonment({ ...identity, expiresAt: 100, reason: "retry" }), /abandonment reason/u); + assert.throws(() => validateHostClaimAbandonment({ ...abandonment, reason: "expired", destination: "burn" }), /invalid host claim/u); +}); diff --git a/src/loops/run/run-journal.mjs b/src/loops/run/run-journal.mjs index f9364008..73120eae 100644 --- a/src/loops/run/run-journal.mjs +++ b/src/loops/run/run-journal.mjs @@ -4,15 +4,21 @@ import { join } from "node:path"; export const MAX_JOURNAL_RECORDS = 256; const MAX_RECORDS = MAX_JOURNAL_RECORDS, MAX_BYTES = 4 * 1024 * 1024, MAX_RECORD = 131072; -const TYPES = new Set(["run-created", "lease-acquired", "lease-released", "lease-revoked", "state-changed", "node-started", "invocation-started", "invocation-result", "candidate-bound", "system-outcome", "terminal-node-committed", "failure-routed", "edge-taken"]); -const names = { "run-created": ["schema", "runId", "itemRef", "graph", "authorityRequired"], "lease-acquired": ["generation", "token"], "lease-released": ["generation", "token"], "lease-revoked": ["generation", "token"], "state-changed": ["from", "to", "cause"], "node-started": ["nodeId", "attempt"], "invocation-started": ["nodeId", "attempt", "invocationId"], "system-outcome": ["kind", "summary"], "terminal-node-committed": ["kind", "summary", "from", "to", "nodeId", "attempt"], "invocation-result": ["invocationId", "kind", "summary", "outputBytes", "candidateId"], "candidate-bound": ["candidateId", "candidateContext"], "failure-routed": ["from", "kind", "to"], "edge-taken": ["from", "on", "to"] }; +const TYPES = new Set(["run-created", "lease-acquired", "lease-released", "lease-revoked", "state-changed", "node-started", "invocation-started", "external-claim-bound", "external-claim-resolved", "external-report-accepted", "invocation-result", "candidate-bound", "system-outcome", "terminal-node-committed", "failure-routed", "edge-taken"]); +const names = { "run-created": ["schema", "runId", "itemRef", "graph", "authorityRequired"], "lease-acquired": ["generation", "token"], "lease-released": ["generation", "token"], "lease-revoked": ["generation", "token"], "state-changed": ["from", "to", "cause"], "node-started": ["nodeId", "attempt"], "invocation-started": ["nodeId", "attempt", "invocationId"], "external-claim-bound": ["claim", "envelopeDigest", "invocationId"], "external-claim-resolved": ["claimId", "invocationId", "reason"], "external-report-accepted": ["claimId", "reportDigest", "invocationId", "kind", "summary", "outputBytes", "candidateId", "findings", "resolvedFindingIds", "telemetry"], "system-outcome": ["kind", "summary"], "terminal-node-committed": ["kind", "summary", "from", "to", "nodeId", "attempt"], "invocation-result": ["invocationId", "kind", "summary", "outputBytes", "candidateId"], "candidate-bound": ["candidateId", "candidateContext"], "failure-routed": ["from", "kind", "to"], "edge-taken": ["from", "on", "to"] }; +const legacyExternalReport = ["claimId", "reportDigest", "invocationId", "kind", "summary", "outputBytes", "candidateId"]; const fileName = (sequence) => `${String(sequence).padStart(16, "0")}.json`; const tempName = /^\.append-[a-f0-9]{16}\.tmp$/u; const fail = (message, code = "EJOURNAL") => { throw Object.assign(new Error(`Run journal: ${message}`), { code }); }; const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; -export function validateJournalEvent(type, payload) { if (!TYPES.has(type) || !exact(payload, names[type])) fail("event payload is not closed"); return payload; } +export function validateJournalEvent(type, payload) { + if (!TYPES.has(type) || !exact(payload, names[type]) + && !(type === "external-report-accepted" && exact(payload, legacyExternalReport))) + fail("event payload is not closed"); + return payload; +} export function createJournalRecord({ sequence, prevDigest, at, type, payload }) { if (!Number.isSafeInteger(sequence) || sequence < 1 || !Number.isSafeInteger(at) || at < 0 || !(prevDigest === null || /^sha256:[a-f0-9]{64}$/u.test(prevDigest))) fail("invalid record header"); validateJournalEvent(type, payload); diff --git a/src/loops/run/run-store.mjs b/src/loops/run/run-store.mjs index a6c45c67..667b463f 100644 --- a/src/loops/run/run-store.mjs +++ b/src/loops/run/run-store.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { closeSync, constants, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { closeSync, constants, existsSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { withDirectoryLock } from "../../server/dir-lock.mjs"; import { isRunRef } from "./run-ref.mjs"; @@ -11,12 +11,21 @@ import { publishLoopProjectionInvalidation } from "../events/projection-events.m import { currentRunAuthority } from "./current-authority.mjs"; import { loadFrozenRecipe } from "../dsl/frozen.mjs"; import { loadBoundPolicy } from "./run-artifacts.mjs"; +import { createHostClaimAbandonment, hostClaimExpired, validateHostClaim } from "./run-claim.mjs"; +import { validateHostExecutionEnvelope, validateHostExecutionReport } from "../contracts/host-execution.mjs"; const fail = (message, code = "ERUN_STORE") => { throw Object.assign(new Error(`Run store: ${message}`), { code }); }; const runName = (id) => Buffer.from(id).toString("hex"); -export function runStore(repoRoot, { clock = () => Date.now(), random = randomBytes, hooks = {}, publishProjection = publishLoopProjectionInvalidation } = {}) { +export function runStore(repoRoot, { clock = () => Date.now(), random = randomBytes, hooks = {}, publishProjection = publishLoopProjectionInvalidation, journalMaximum = MAX_JOURNAL_RECORDS } = {}) { const root = resolve(repoRoot), base = join(root, ".local", "burnlist", "loop", "m2"), runs = join(base, "runs"), now = () => { const value = clock(); if (!Number.isSafeInteger(value) || value < 0) fail("invalid clock"); return value; }; - const pathFor = (id) => join(runs, runName(id)), journalFor = (id) => join(pathFor(id), "journal"), lockFor = (id) => join(pathFor(id), ".lock"), proofPath = (id) => join(pathFor(id), ".recovery-proof"), authorityPath = (id) => join(pathFor(id), "dispatch-authority.json"), currentLock = join(base, ".current-runs.lock"), initialize = () => mkdirSync(runs, { recursive: true, mode: 0o700 }), currentAuthority = () => currentRunAuthority({ root, base, random }); + if (!Number.isInteger(journalMaximum) || journalMaximum < 2 || journalMaximum > MAX_JOURNAL_RECORDS) fail("invalid journal maximum", "EJOURNAL"); + const pathFor = (id) => join(runs, runName(id)), journalFor = (id) => join(pathFor(id), "journal"), lockFor = (id) => join(pathFor(id), ".lock"), proofPath = (id) => join(pathFor(id), ".recovery-proof"), authorityPath = (id) => join(pathFor(id), "dispatch-authority.json"), executionRoot = (id) => join(pathFor(id), "host-executions"), executionPath = (id, digest) => { + if (!/^sha256:[a-f0-9]{64}$/u.test(digest)) fail("external claim envelope digest is invalid", "ECLAIM"); + return join(executionRoot(id), `${digest.slice(7)}.json`); + }, reportRoot = (id) => join(pathFor(id), "host-reports"), reportPath = (id, digest) => { + if (!/^sha256:[a-f0-9]{64}$/u.test(digest)) fail("external report digest is invalid", "EREPORT"); + return join(reportRoot(id), `${digest.slice(7)}.json`); + }, currentLock = join(base, ".current-runs.lock"), initialize = () => mkdirSync(runs, { recursive: true, mode: 0o700 }), currentAuthority = () => currentRunAuthority({ root, base, random }); const assertId = (id) => { if (!isRunRef(id)) fail("invalid RunRef"); return id; }; const locked = (id, fn) => { assertId(id); initialize(); return withDirectoryLock({ lockPath: lockFor(id), reclaimLiveAfterAge: false, errorFactory: () => fail("run is locked", "ELOCKED"), fn }); }; const replay = (id) => { @@ -48,7 +57,7 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy } else if (journal[0].value.payload.authorityRequired) fail("sealed dispatch authority is unavailable", "EAUTHORITY"); return Object.freeze({ runId: id, journal, loopIdentity, agentRoutes, ...folded }); }; - const retainsTerminalReserve = (current, writes = 1) => current.projection.sequence + writes < MAX_JOURNAL_RECORDS; + const retainsTerminalReserve = (current, writes = 1) => current.projection.sequence + writes < journalMaximum; const terminalKind = { converged: "converged", "needs-human": "lost", failed: "error", stopped: "cancelled", "budget-exhausted": "exhausted" }; function prospective(id, current, type, payload, at = now()) { if (!retainsTerminalReserve(current)) fail("journal terminal reserve is required", "EJOURNAL"); @@ -63,6 +72,40 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy function writeRecoveryProof(id, value) { const checked = { schema: "burnlist-loop-m2-recovery-proof@1", runId: id, generation: value.generation, token: value.token, recoveryProof: value.recoveryProof }, bytes = Buffer.from(`${JSON.stringify(checked)}\n`), temporary = `${proofPath(id)}.${random(8).toString("hex")}.tmp`; if (bytes.length > 1024) fail("recovery proof exceeds bounds"); let fd; try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, proofPath(id)); syncParent(id); } finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } } function readRecoveryProof(id) { let fd; try { const path = proofPath(id), entry = lstatSync(path); if (!entry.isFile() || entry.isSymbolicLink()) fail("recovery proof is corrupt"); fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0)); const before = fstatSync(fd); if (!before.isFile() || (before.mode & 0o777) !== 0o600 || before.size < 2 || before.size > 1024) fail("recovery proof is corrupt"); const bytes = Buffer.alloc(before.size); if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) fail("recovery proof changed while reading"); const after = fstatSync(fd); if (!after.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) fail("recovery proof changed while reading"); const value = JSON.parse(bytes.toString("utf8")); if (!value || Object.keys(value).length !== 5 || value.schema !== "burnlist-loop-m2-recovery-proof@1" || value.runId !== id || !Number.isSafeInteger(value.generation) || !/^[a-f0-9]{64}$/u.test(value.token) || !/^[a-f0-9]{64}$/u.test(value.recoveryProof)) fail("recovery proof is corrupt"); return value; } catch (error) { if (error?.code === "ENOENT") fail("lost-owner proof is unavailable", "ELOST_PROOF"); throw error; } finally { if (fd !== undefined) closeSync(fd); } } function clearRecoveryProof(id) { rmSync(proofPath(id), { force: true }); syncParent(id); } + function writeExecution(id, envelope) { + const directory = executionRoot(id), target = executionPath(id, envelope.digest), temporary = `${target}.${random(8).toString("hex")}.tmp`; + mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (existsSync(target)) { + const existing = readExecution(id, envelope.digest); + if (!existing.bytes.equals(envelope.bytes)) fail("external claim envelope differs", "ECLAIM"); + return existing; + } + let fd; + try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, envelope.bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, target); syncParent(id); return envelope; } + finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } + function readExecution(id, digest) { + try { + const target = executionPath(id, digest), entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 || entry.size < 2 || entry.size > 400_000) fail("external claim envelope is corrupt", "ECLAIM"); + const envelope = validateHostExecutionEnvelope(readFileSync(target)); + if (envelope.bytes.length !== entry.size || envelope.digest !== digest) fail("external claim envelope changed", "ECLAIM"); + return envelope; + } catch (error) { if (error?.code === "ENOENT") fail("external claim envelope is unavailable", "ECLAIM"); if (error?.code === "ECLAIM") throw error; fail("external claim envelope is corrupt", "ECLAIM"); } + } + function writeReport(id, report) { + const directory = reportRoot(id), target = reportPath(id, report.digest), temporary = `${target}.${random(8).toString("hex")}.tmp`; + mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (existsSync(target)) { + const entry = lstatSync(target); + if (!entry.isFile() || entry.isSymbolicLink() || (entry.mode & 0o777) !== 0o600 + || entry.size !== report.bytes.length || !readFileSync(target).equals(report.bytes)) fail("external report differs", "EREPORT"); + return; + } + let fd; + try { fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); writeFileSync(fd, report.bytes); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temporary, target); syncDirectory(directory); } + finally { if (fd !== undefined) closeSync(fd); rmSync(temporary, { force: true }); } + } function sealedAuthority(id, value) { const keys = ["schema", "runId", "assignmentId", "itemRef", "itemRevision", "itemText", "frozenRecipe", "policy"]; if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every((key, index) => Object.keys(value)[index] === key) @@ -103,12 +146,12 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy finally { if (fd !== undefined) closeSync(fd); } } function terminalizeCurrent(id, current, { kind = "exhausted", summary = "journal" } = {}, at = now()) { - if (current.projection.sequence >= MAX_JOURNAL_RECORDS) fail("journal has no terminal capacity", "EJOURNAL"); + if (current.projection.sequence >= journalMaximum) fail("journal has no terminal capacity", "EJOURNAL"); const selected = current.execution.system ?? (isTerminalState(current.projection.state) ? { kind: terminalKind[current.projection.state], summary: "journal-cleanup" } : { kind, summary }), targetState = isTerminalState(current.projection.state) ? current.projection.state : atomicTerminalState(selected.kind), targetNode = selected.kind === "converged" ? current.graph.nodes.find((node) => node.kind === "terminal" && node.state === "converged")?.id : current.graph.failurePolicy[selected.kind], alreadyStarted = current.execution.nodeId === targetNode && current.execution.started, attempt = alreadyStarted ? current.execution.attempts[targetNode] : (current.execution.attempts[targetNode] ?? 0) + 1, record = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type: "terminal-node-committed", payload: { kind: selected.kind, summary: selected.summary, from: current.projection.state, to: targetState, nodeId: targetNode, attempt } }); foldRun([...current.journal, record]); appendJournalRecord({ journalDirectory: journalFor(id), record }); clearRecoveryProof(id); return Object.freeze({ record, ...replay(id) }); } - const append = (id, lease, type, payload) => locked(id, () => { const current = replay(id); assertLease(current, lease); const at = now(); if (current.projection.sequence >= MAX_JOURNAL_RECORDS) fail("journal has no terminal capacity", "EJOURNAL"); const candidate = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type, payload }), folded = foldRun([...current.journal, candidate]); if (type === "state-changed" && isTerminalState(payload.to)) return terminalizeCurrent(id, current, { kind: terminalKind[payload.to], summary: payload.cause }, at); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current, {}, at); if (["node-started", "invocation-started", "invocation-result", "edge-taken"].includes(type) && folded.execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return terminalizeCurrent(id, current, { summary: "minutes" }, at); appendJournalRecord({ journalDirectory: journalFor(id), record: candidate }); return Object.freeze({ record: candidate, ...replay(id) }); }); + const append = (id, lease, type, payload) => locked(id, () => { const current = replay(id); assertLease(current, lease); const at = now(); if (current.projection.sequence >= journalMaximum) fail("journal has no terminal capacity", "EJOURNAL"); const candidate = createJournalRecord({ sequence: current.projection.sequence + 1, prevDigest: current.projection.journalDigest, at, type, payload }), folded = foldRun([...current.journal, candidate]); if (type === "state-changed" && isTerminalState(payload.to)) return terminalizeCurrent(id, current, { kind: terminalKind[payload.to], summary: payload.cause }, at); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current, {}, at); if (["node-started", "invocation-started", "invocation-result", "edge-taken"].includes(type) && folded.execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return terminalizeCurrent(id, current, { summary: "minutes" }, at); appendJournalRecord({ journalDirectory: journalFor(id), record: candidate }); return Object.freeze({ record: candidate, ...replay(id) }); }); const acquireLease = (id) => locked(id, () => { let current = replay(id); if (current.execution.lease) fail("run already has a lease", "ELEASED"); if (isTerminalState(current.projection.state)) fail("run is terminal"); const writes = ["prepared", "paused"].includes(current.projection.state) ? 2 : 1; if (!retainsTerminalReserve(current, writes)) return terminalizeCurrent(id, current); @@ -118,7 +161,125 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy const releaseLease = (id, lease) => locked(id, () => { const current = replay(id); assertLease(current, lease); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current); const result = prospective(id, current, "lease-released", { generation: lease?.generation, token: lease?.token }); clearRecoveryProof(id); return result; }); const recoverLease = (id, proof) => locked(id, () => { const current = replay(id), expected = readRecoveryProof(id); if (!proof || proof.generation !== expected.generation || proof.recoveryProof !== expected.recoveryProof) fail("lost-owner proof is invalid", "ELOST_PROOF"); const held = current.execution.lease; if (!held || held.generation !== proof.generation || held.token !== expected.token) fail("owner generation changed", "ESTALE_LEASE"); if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current); const result = prospective(id, current, "lease-revoked", { generation: held.generation, token: held.token }); clearRecoveryProof(id); return result; }); const terminalize = (id, lease, kind, summary) => locked(id, () => { const current = replay(id); assertLease(current, lease); return terminalizeCurrent(id, current, { kind, summary }); }); - function createRun({ runId, itemRef, graph, authority = null }) { + const bindExternalClaim = (id, lease, { claim, envelope }) => locked(id, () => { + const current = replay(id); assertLease(current, lease); + if (current.execution.externalClaim) return readExternalClaim(id, current); + if (current.execution.terminal || current.execution.started || current.execution.invocation || current.execution.node?.kind !== "agent") fail("current node is not an unstarted agent", "ECLAIM"); + const checkedClaim = validateHostClaim(claim), checkedEnvelope = validateHostExecutionEnvelope(envelope), authority = readAuthority(id), issued = now(); + const frozen = loadFrozenRecipe(Buffer.from(authority.frozenRecipe, "base64")), policy = loadBoundPolicy(Buffer.from(authority.policy, "base64")); + if (hostClaimExpired(checkedClaim, issued) || checkedEnvelope.value.expiresAt <= issued || checkedClaim.executionDigest !== checkedEnvelope.digest + || checkedClaim.runId !== id || checkedClaim.nodeId !== current.execution.nodeId || checkedClaim.attempt !== current.execution.attempt + 1 + || checkedClaim.assignmentId !== authority.assignmentId || checkedClaim.claimId !== checkedEnvelope.value.claimId + || checkedClaim.assignmentId !== checkedEnvelope.value.assignmentId || checkedClaim.inputCandidate !== checkedEnvelope.value.inputCandidate + || checkedEnvelope.value.nodeId !== checkedClaim.nodeId || checkedEnvelope.value.attempt !== checkedClaim.attempt + || checkedEnvelope.value.recipeRevision !== frozen.revisions.executable || checkedEnvelope.value.policyRevision !== policy.revision) fail("external claim does not bind the current Run", "ECLAIM"); + if (current.execution.node.mode === "review" && checkedClaim.inputCandidate !== current.execution.candidate?.id) + fail("review claim is not bound to the current candidate", "ECLAIM"); + writeExecution(id, checkedEnvelope); + // One journal record makes the host-visible claim indivisible: replay never + // observes a started agent invocation that lacks its sealed host envelope. + prospective(id, current, "external-claim-bound", { claim: checkedClaim, envelopeDigest: checkedEnvelope.digest, invocationId: checkedEnvelope.value.invocationId }); + return readExternalClaim(id, replay(id)); + }); + function readExternalClaim(id, current = replay(id)) { + const binding = current.execution.externalClaim; + if (!binding) return null; + const envelope = readExecution(id, binding.envelopeDigest); + if (envelope.digest !== binding.envelopeDigest || envelope.value.claimId !== binding.claim.claimId + || envelope.value.runId !== binding.claim.runId || envelope.value.nodeId !== binding.claim.nodeId + || envelope.value.attempt !== binding.claim.attempt || envelope.value.assignmentId !== binding.claim.assignmentId + || envelope.value.inputCandidate !== binding.claim.inputCandidate || envelope.value.expiresAt !== binding.claim.expiresAt) fail("external claim envelope does not match journal", "ECLAIM"); + return Object.freeze({ claim: binding.claim, envelope: envelope.bytes }); + } + const abandonExternalClaim = (id, lease, abandonment) => locked(id, () => { + const current = replay(id); assertLease(current, lease); const active = readExternalClaim(id, current); + if (!active) fail("external claim is unavailable", "ECLAIM"); + const checked = createHostClaimAbandonment(abandonment), claim = active.claim; + if (["runId", "claimId", "nodeId", "attempt", "assignmentId", "inputCandidate", "executionDigest", "expiresAt"].some((key) => checked[key] !== claim[key]) + || checked.reason === "expired" && !hostClaimExpired(claim, now())) fail("external claim abandonment is stale", "ECLAIM"); + return terminalizeCurrent(id, current, { kind: "lost", summary: `external claim ${checked.reason}` }); + }); + const resolveExternalClaim = (id, lease, { claimId, invocationId, reason }) => locked(id, () => { + const current = replay(id); assertLease(current, lease); + const active = readExternalClaim(id, current); + if (!active || reason !== "paused" || active.claim.claimId !== claimId + || current.execution.invocation?.invocationId !== invocationId) fail("external claim resolution is stale", "ECLAIM"); + return prospective(id, current, "external-claim-resolved", { claimId, invocationId, reason }); + }); + const acceptExternalReport = (id, lease, reportBytes, candidateForBoundary) => locked(id, () => { + const current = replay(id); + const raw = Buffer.from(reportBytes), parsed = parseBoundedObject(raw, { maximumBytes: 262_144, maximumDepth: 5, label: "host execution report" }); + const claimId = parsed?.result?.claimId; + const bindingRecord = [...current.journal].reverse().find((record) => record.value.type === "external-claim-bound" + && record.value.payload.claim.claimId === claimId); + if (!bindingRecord) fail("external report claim is unavailable", "EREPORT"); + const binding = bindingRecord.value.payload, envelope = readExecution(id, binding.envelopeDigest); + const boundary = typeof candidateForBoundary === "function" ? candidateForBoundary() : candidateForBoundary; + const taskBoundary = current.execution.node.mode === "task"; + const candidateId = boundary?.candidateId ?? (taskBoundary ? boundary?.id : null) ?? null; + const candidateContext = boundary?.candidateContext ?? (taskBoundary ? boundary?.context : null) ?? null; + const observedCandidateId = boundary?.observedCandidateId ?? (!taskBoundary ? boundary?.id : null) ?? null; + const report = validateHostExecutionReport(raw, { + envelope, mode: current.graph.nodes.find((node) => node.id === binding.claim.nodeId)?.mode, + openFindings: current.execution.node.mode === "review" ? current.execution.openFindings : new Map(), + }); + const prior = current.journal.find((record) => record.value.type === "external-report-accepted" + && record.value.payload.claimId === claimId); + if (prior && prior.value.payload.reportDigest !== report.digest) fail("external report conflicts with accepted report", "EREPORT"); + // A cut after edge-taken leaves the old lease in the journal. The exact + // report retry owns the durable transaction tail and must release it; + // returning here would fence the next node forever. + if (prior && current.execution.nodeId !== binding.claim.nodeId) { + if (current.execution.lease) { + assertLease(current, lease); + if (!retainsTerminalReserve(current)) return terminalizeCurrent(id, current, { kind: "exhausted", summary: "journal" }); + prospective(id, current, "lease-released", { generation: lease.generation, token: lease.token }); + clearRecoveryProof(id); + } + return replay(id); + } + assertLease(current, lease); + const active = prior ? null : readExternalClaim(id, current); + if (!prior && (!active || active.claim.claimId !== claimId || hostClaimExpired(active.claim, now()))) fail("external report claim is stale", "EREPORT"); + const result = report.value.result; + if (result.outcome === "complete") { + if (!/^cm1-sha256:[a-f0-9]{64}$/u.test(candidateId) || typeof candidateContext !== "string" || !candidateContext) fail("completed task candidate is unavailable", "EREPORT"); + if (observedCandidateId !== null) fail("unexpected observed report candidate", "EREPORT"); + } else { + if (candidateId !== null || candidateContext !== null) fail("unexpected report candidate", "EREPORT"); + if (current.execution.node.mode === "review") { + if (!/^cm1-sha256:[a-f0-9]{64}$/u.test(observedCandidateId) + || observedCandidateId !== binding.claim.inputCandidate + || observedCandidateId !== current.execution.candidate?.id) fail("review report candidate drifted", "EREPORT"); + } else if (observedCandidateId !== null) fail("unexpected observed report candidate", "EREPORT"); + } + // This operation is a durable tail, not a collection of independently + // optional writes. Reserve report + optional candidate + edge + release + // before committing its first semantic record. If it cannot fit, consume + // the one retained terminal record instead of stranding the host lease. + const remaining = (prior ? 0 : 1) + (result.outcome === "complete" && !current.execution.candidate ? 1 : 0) + 2; + if (!retainsTerminalReserve(current, remaining)) return terminalizeCurrent(id, current, { kind: "exhausted", summary: "journal" }); + let advanced = current; + if (!prior) { + writeReport(id, report); + advanced = prospective(id, current, "external-report-accepted", { + claimId, reportDigest: report.digest, invocationId: result.invocationId, kind: result.outcome, + summary: `host reported ${result.outcome}`, outputBytes: report.bytes.length, + candidateId: current.execution.node.mode === "task" ? null : current.execution.candidate?.id ?? null, + findings: result.findings, resolvedFindingIds: result.resolvedFindingIds, telemetry: report.value.telemetry, + }); + hooks.afterExternalReportAccepted?.({ id, lease, claimId, reportDigest: report.digest }); + } + if (result.outcome === "complete" && !advanced.execution.candidate) advanced = prospective(id, advanced, "candidate-bound", { candidateId, candidateContext }); + const edge = advanced.graph.edges.find((item) => item.from === advanced.execution.nodeId && item.on === result.outcome); + if (!edge) fail("external report outcome has no declared edge", "EREPORT"); + advanced = prospective(id, advanced, "edge-taken", { from: edge.from, on: edge.on, to: edge.to }); + hooks.afterExternalEdgeTaken?.({ id, lease, claimId, edge }); + prospective(id, advanced, "lease-released", { generation: lease.generation, token: lease.token }); + clearRecoveryProof(id); + return replay(id); + }); + function createRun({ runId, itemRef, graph, authority = null, allowSupersedeConverged = false }) { assertId(runId); if (typeof itemRef !== "string" || !itemRef || itemRef.length > 512) fail("invalid creation input"); validateGraph(graph); initialize(); const target = pathFor(runId), staging = join(runs, `.create-${random(8).toString("hex")}.tmp`); if (existsSync(target)) fail("run already exists", "EEXIST"); mkdirSync(staging, { recursive: false, mode: 0o700 }); @@ -137,7 +298,8 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy // recovered only by the same sealed Run identity. } else { const prior = replay(previous.runId).projection; - if (!["failed", "stopped", "budget-exhausted", "needs-human"].includes(prior.state)) fail("current Run is still executable", "ECURRENT"); + if (!["failed", "stopped", "budget-exhausted", "needs-human"].includes(prior.state) + && !(prior.state === "converged" && allowSupersedeConverged)) fail("current Run is still executable", "ECURRENT"); currentAuthority().write([...entries.filter((entry) => entry.itemRef !== current.itemRef), { itemRef: current.itemRef, runId, assignmentId: current.assignmentId }]); } } else { @@ -151,14 +313,38 @@ export function runStore(repoRoot, { clock = () => Date.now(), random = randomBy } // Publication is observational: commit and release the journal lock before notifying readers. const published = (result) => { try { publishProjection(root, result); } catch {} return result; }; + function resolveClaimRef(claimId) { + if (!/^cl1-sha256:[a-f0-9]{64}$/u.test(claimId)) fail("invalid ClaimRef", "ECLAIM"); + if (!existsSync(runs)) fail("ClaimRef is missing", "ECLAIM"); + const entries = readdirSync(runs, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")); + if (entries.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^[a-f0-9]+$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS"); + const matches = []; + for (const entry of entries) { + const runId = Buffer.from(entry.name, "hex").toString("utf8"); + if (!isRunRef(runId) || Buffer.from(runId).toString("hex") !== entry.name) fail("run directory is corrupt", "EBOUNDS"); + const current = replay(runId), bound = current.journal.some((record) => record.value.type === "external-claim-bound" + && record.value.payload.claim.claimId === claimId); + if (!bound) continue; + const accepted = current.journal.some((record) => record.value.type === "external-report-accepted" + && record.value.payload.claimId === claimId); + if (!accepted && current.execution.externalClaim?.claim.claimId !== claimId) fail("ClaimRef is stale", "ECLAIM"); + matches.push(runId); + } + if (!matches.length) fail("ClaimRef is missing", "ECLAIM"); + if (matches.length !== 1) fail("ClaimRef is ambiguous", "ECLAIM"); + return matches[0]; + } return Object.freeze({ createRun: (...input) => published(createRun(...input)), replay, read: replay, append: (...input) => published(append(...input)), acquireLease: (...input) => published(acquireLease(...input)), releaseLease: (...input) => published(releaseLease(...input)), recoverLease: (...input) => published(recoverLease(...input)), - terminalize: (...input) => published(terminalize(...input)), list: () => { + terminalize: (...input) => published(terminalize(...input)), bindExternalClaim: (...input) => published(bindExternalClaim(...input)), + readExternalClaim, abandonExternalClaim: (...input) => published(abandonExternalClaim(...input)), + resolveExternalClaim: (...input) => published(resolveExternalClaim(...input)), + acceptExternalReport: (...input) => published(acceptExternalReport(...input)), list: () => { if (!existsSync(runs)) return []; const entries = readdirSync(runs, { withFileTypes: true }), staging = entries.filter((entry) => /^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)), visible = entries.filter((entry) => !/^\.create-[a-f0-9]{16}\.tmp$/u.test(entry.name)); if (staging.length > 128 || visible.length > 128 || entries.some((entry) => !entry.isDirectory() || !/^(?:[a-f0-9]+|\.create-[a-f0-9]{16}\.tmp)$/u.test(entry.name))) fail("run directory exceeds bounds", "EBOUNDS"); return visible.sort((a, b) => a.name.localeCompare(b.name)) .map((entry) => replay(Buffer.from(entry.name, "hex").toString("utf8")).projection); - }, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, currentPath: join(base, "current-runs.json") }) }); + }, resolveClaimRef, readAuthority, readCurrentRun(itemRef) { if (!existsSync(base)) return null; const values = currentAuthority().read().filter((entry) => entry.itemRef === itemRef); if (values.length > 1) fail("current Run binding is ambiguous", "ECURRENT"); return values[0] ?? null; }, paths: Object.freeze({ base, runs, pathFor, journalFor, authorityPath, executionPath, reportPath, currentPath: join(base, "current-runs.json") }) }); } diff --git a/src/loops/run/run-test-fixtures.mjs b/src/loops/run/run-test-fixtures.mjs index c788b97a..4cd583cc 100644 --- a/src/loops/run/run-test-fixtures.mjs +++ b/src/loops/run/run-test-fixtures.mjs @@ -15,7 +15,10 @@ import { presentRun } from "./read-projection.mjs"; export const d = (prefix, char) => `${prefix}:${char.repeat(64)}`; export const fixtureRunId = "run:01arz3ndektsv4rrffq69g5fav"; export const fixtureItemRef = "item:260722-001#L29"; -export const m4ProgressOutcomes = ["complete", "pass", "reject", "complete", "pass", "approve"]; +export const m4ProgressOutcomes = [ + "complete", "complete", "complete", "pass", "reject", + "complete", "complete", "pass", "approve", "complete", "pass", "approve", +]; let frozenPromise; const fixtureBinary = "/bin/sh"; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); @@ -67,9 +70,13 @@ export async function runM4ProgressFixture({ const baseClock = clock ?? (() => at++); const store = runStore(repoRoot, { clock: baseClock }); store.createRun({ runId, itemRef, graph: frozenGraph }); - const runner = createRunRunner({ store, runId, invoke: async () => { + const runner = createRunRunner({ store, runId, invoke: async ({ nodeId }) => { const outcome = source.shift(); if (!outcome) throw new Error(`run fixture: missing outcome for ${runId}`); + const node = frozenGraph.nodes.find((item) => item.id === nodeId); + const permitted = node?.kind === "agent" ? (node.mode === "task" ? ["complete"] : ["approve", "reject", "escalate"]) + : node?.kind === "check" ? ["pass", "fail"] : []; + if (!permitted.includes(outcome)) throw new Error(`run fixture: ${outcome} is not valid for ${nodeId}`); return { kind: outcome, summary: outcome, outputBytes: 1 }; }}); const snapshots = []; @@ -125,9 +132,9 @@ const fs=require("node:fs"),a=process.argv.slice(2),prompt=a.at(-1),lines=Object let outcome="complete";const counter=process.env.BURNLIST_FAKE_COUNTER; if(process.env.BURNLIST_FAKE_STARTED){const marker=process.env.BURNLIST_FAKE_STARTED,tmp=marker+"."+process.pid+".tmp";fs.writeFileSync(tmp,JSON.stringify({pid:process.pid,node:lines.node,attempt:Number(lines.attempt)}));fs.renameSync(tmp,marker);} const wait=Number(process.env.BURNLIST_FAKE_WAIT_MS||0);if(Number.isSafeInteger(wait)&&wait>0)Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,wait); -if(counter){const attempt=Number(lines.attempt),index=lines.node==="implement"?(attempt-1)*2:lines.node==="review"?(attempt-1)*2+1:Number(fs.readFileSync(counter,"utf8"));outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete").split(",")[index]||"approve";fs.writeFileSync(counter,String(Math.max(Number(fs.readFileSync(counter,"utf8")),index+1)));} +if(counter){const index=Number(fs.readFileSync(counter,"utf8"));outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete").split(",")[index]||"approve";fs.writeFileSync(counter,String(index+1));} if(lines.node==="implement"&&outcome==="complete")fs.writeFileSync(process.cwd()+"/src/fake-maker-candidate.txt","maker-attempt="+lines.attempt+"\\n"); -const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome}; +const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome,findings:[],resolvedFindingIds:[]}; const mode=process.env.BURNLIST_FAKE_FINAL_MODE;if(mode==="stale")final.inputCandidate="cm1-sha256:"+"f".repeat(64); process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"s-"+process.pid,model:a[a.indexOf("-m")+1]})+"\\n"); process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:mode==="malformed"?"not-json":JSON.stringify(final)}})+"\\n"); diff --git a/src/loops/run/runner.mjs b/src/loops/run/runner.mjs index 7c3a4965..9fe9a9aa 100644 --- a/src/loops/run/runner.mjs +++ b/src/loops/run/runner.mjs @@ -11,8 +11,9 @@ function boundedSummary(value) { while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; return bytes.subarray(0, end).toString("utf8"); } -export function createRunRunner({ store, runId, invoke, bindCandidate = null }) { +export function createRunRunner({ store, runId, invoke, bindCandidate = null, executePreparedAgent = null }) { if (!store?.replay || !store?.append || !store?.acquireLease || !store?.terminalize || typeof invoke !== "function") fail("invalid runner input"); + if (executePreparedAgent !== null && typeof executePreparedAgent !== "function") fail("invalid prepared agent executor"); let lease = null, pauseRequested = false, stopRequested = false, cancelRequested = false, cancelWake = null; const read = () => store.replay(runId), append = (type, payload) => store.append(runId, lease, type, payload); function transition(to, cause) { const execution = read().execution; return append("state-changed", { from: execution.state, to, cause }); } @@ -22,7 +23,18 @@ export function createRunRunner({ store, runId, invoke, bindCandidate = null }) async function step() { let current = read(), execution = current.execution; if (execution.terminal) return { kind: "terminal", state: execution.state }; if (!lease) lease = store.acquireLease(runId).lease; current = read(); execution = current.execution; if (execution.terminal) { lease = null; return { kind: "terminal", state: execution.state }; } if (execution.system) return routeSystem(current, execution); const node = execution.node; if (execution.budget.elapsedMilliseconds >= current.graph.budget.maxMinutes * 60_000) return system("exhausted", "minutes"); - if (!execution.started) { const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, node }); if (exhausted) return system("exhausted", exhausted); return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); } + if (!execution.started) { + const exhausted = budgetReason({ folded: execution.budget, graph: current.graph, node }); if (exhausted) return system("exhausted", exhausted); + // Agent work is prepared and accepted through the same claim transaction + // as host execution. Checks and graph-only nodes retain this runner's + // existing deterministic path. + if (node.kind === "agent" && executePreparedAgent) { + const completed = await executePreparedAgent({ runId, lease, node }); + if (completed?.released) lease = null; + return { kind: "prepared-agent" }; + } + return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); + } if (node.kind === "terminal") return transition(node.state, "graph"); if (node.kind === "gate") return edge(execution, gateDecision(execution, current.graph)); if (execution.result) { @@ -83,7 +95,7 @@ export function createRunRunner({ store, runId, invoke, bindCandidate = null }) if (current.execution.terminal) { if (lease && current.execution.lease) store.releaseLease(runId, lease); lease = null; return read(); } if (pauseRequested) return pause(); } } - function requestPause() { pauseRequested = true; cancelRequested = true; invoke.cancel?.(); cancelWake?.(); } - function requestStop() { stopRequested = true; cancelRequested = true; invoke.cancel?.(); cancelWake?.(); } + function requestPause() { pauseRequested = true; cancelRequested = true; invoke.cancel?.(); executePreparedAgent?.cancel?.(); cancelWake?.(); } + function requestStop() { stopRequested = true; cancelRequested = true; invoke.cancel?.(); executePreparedAgent?.cancel?.(); cancelWake?.(); } return Object.freeze({ step, run, pause, stop, requestPause, requestStop, replay: read, get lease() { return lease; } }); } diff --git a/src/loops/run/state-machine.mjs b/src/loops/run/state-machine.mjs index 3457040a..fea055fe 100644 --- a/src/loops/run/state-machine.mjs +++ b/src/loops/run/state-machine.mjs @@ -1,7 +1,10 @@ -import { validateClosedIr } from "../dsl/ir-validate.mjs"; +import { validateReplayIr } from "../dsl/ir-validate.mjs"; import { isRunRef } from "./run-ref.mjs"; import { foldBudgets } from "./budgets.mjs"; import { isSystemOutcome, validateNormalizedResult } from "./run-result.mjs"; +import { validateHostClaim } from "./run-claim.mjs"; +import { nextOpenFindings, validateFindingSet } from "../contracts/finding.mjs"; +import { validateHostTelemetry } from "../contracts/host-execution.mjs"; const TERMINAL = new Set(["converged", "needs-human", "failed", "stopped", "budget-exhausted"]), SYSTEM = { error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" }, ATOMIC = { ...SYSTEM, converged: "converged" }; const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); @@ -9,7 +12,7 @@ const fail = (message) => { throw Object.assign(new Error(`Run state machine: ${ export const isTerminalState = (state) => TERMINAL.has(state); export const systemState = (kind) => SYSTEM[kind] ?? fail("unknown system outcome"); export const atomicTerminalState = (kind) => ATOMIC[kind] ?? fail("unknown atomic terminal"); -export function validateGraph(graph) { if (!validateClosedIr(graph)) fail("graph is not canonical closed IR"); return Object.freeze({ nodes: new Map(graph.nodes.map((node) => [node.id, node])), edges: new Map(graph.edges.map((edge) => [`${edge.from}\0${edge.on}`, edge])) }); } +export function validateGraph(graph) { if (!validateReplayIr(graph)) fail("graph is not canonical closed IR"); return Object.freeze({ nodes: new Map(graph.nodes.map((node) => [node.id, node])), edges: new Map(graph.edges.map((edge) => [`${edge.from}\0${edge.on}`, edge])) }); } export function validateStateTransition(from, to, cause) { if (!(["control", "graph", "system"].includes(cause))) fail("invalid transition cause"); const control = (from === "prepared" && ["running", "stopped"].includes(to)) || (from === "running" && ["paused", "stopped"].includes(to)) || (from === "paused" && ["running", "stopped"].includes(to)); @@ -29,7 +32,7 @@ function gateOutcome(runtime, node) { export function foldStateMachine({ graph, records }) { const { nodes, edges } = validateGraph(graph), first = records[0]?.value?.payload; if (!first || first.type !== undefined && records[0].value.type !== "run-created" || !isRunRef(first.runId)) fail("invalid RunRef creation"); - const current = { state: "prepared", generation: 0, lease: null }, runtime = { nodeId: graph.entry, attempts: {}, started: false, invocation: null, result: null, system: null, cycle: 0, evidence: {}, candidate: null, latest: { maker: null, check: null, reviewer: null }, nodes, edges }; + const current = { state: "prepared", generation: 0, lease: null }, runtime = { nodeId: graph.entry, attempts: {}, started: false, invocation: null, externalClaim: null, result: null, system: null, cycle: 0, evidence: {}, candidate: null, openFindings: new Map(), telemetry: null, latest: { maker: null, check: null, reviewer: null }, nodes, edges }; for (const [index, record] of records.entries()) { const { type, payload } = record.value, node = nodes.get(runtime.nodeId); if (!index) continue; if (type === "state-changed") { if (!exact(payload, ["from", "to", "cause"]) || payload.from !== current.state) fail("invalid state event"); validateStateTransition(payload.from, payload.to, payload.cause); if (payload.cause === "control" && payload.to === "paused" && runtime.invocation && !runtime.result) runtime.invocation = null; if (payload.cause === "graph" && (!node || node.kind !== "terminal" || !runtime.started || payload.to !== node.state)) fail("graph terminal bypass"); if (payload.cause === "system" && (!runtime.system || payload.to !== systemState(runtime.system.kind))) fail("system terminal bypass"); current.state = payload.to; continue; } @@ -39,9 +42,38 @@ export function foldStateMachine({ graph, records }) { if (!current.lease || current.state !== "running") fail("active event lacks lease"); if (type === "node-started") { if (!exact(payload, ["nodeId", "attempt"]) || payload.nodeId !== runtime.nodeId || runtime.started || payload.attempt !== (runtime.attempts[payload.nodeId] ?? 0) + 1) fail("invalid node start"); runtime.started = true; runtime.attempts[payload.nodeId] = payload.attempt; if (node.kind === "agent" && node.mode === "task") { runtime.cycle += 1; runtime.candidate = null; } continue; } if (type === "invocation-started") { if (!exact(payload, ["nodeId", "attempt", "invocationId"]) || !runtime.started || runtime.invocation || !["agent", "check"].includes(node.kind) || payload.nodeId !== runtime.nodeId || payload.attempt !== runtime.attempts[payload.nodeId] || !/^[a-f0-9]{32}$/u.test(payload.invocationId)) fail("invalid invocation start"); runtime.invocation = payload; continue; } - if (type === "invocation-result") { - if (!runtime.invocation || runtime.result || !exact(payload, ["invocationId", "kind", "summary", "outputBytes", "candidateId"]) || payload.invocationId !== runtime.invocation.invocationId) fail("invalid invocation result"); + if (type === "external-claim-bound") { + let claim; try { claim = validateHostClaim(payload.claim); } catch { fail("invalid external claim"); } + if (!exact(payload, ["claim", "envelopeDigest", "invocationId"]) || runtime.started || runtime.invocation || runtime.externalClaim || runtime.result || node?.kind !== "agent" + || claim.runId !== first.runId || claim.nodeId !== runtime.nodeId || claim.attempt !== (runtime.attempts[runtime.nodeId] ?? 0) + 1 + || !/^sha256:[a-f0-9]{64}$/u.test(payload.envelopeDigest) || claim.executionDigest !== payload.envelopeDigest + || !/^iv1-sha256:[a-f0-9]{64}$/u.test(payload.invocationId)) fail("invalid external claim binding"); + runtime.started = true; runtime.attempts[runtime.nodeId] = claim.attempt; + runtime.invocation = Object.freeze({ nodeId: claim.nodeId, attempt: claim.attempt, invocationId: payload.invocationId }); + if (node.mode === "task") { runtime.cycle += 1; runtime.candidate = null; } + runtime.externalClaim = Object.freeze({ claim, envelopeDigest: payload.envelopeDigest }); continue; + } + if (type === "external-claim-resolved") { + if (!exact(payload, ["claimId", "invocationId", "reason"]) || payload.reason !== "paused" + || payload.claimId !== runtime.externalClaim?.claim.claimId || payload.invocationId !== runtime.invocation?.invocationId) + fail("invalid external claim resolution"); + runtime.started = false; runtime.invocation = null; runtime.externalClaim = null; runtime.result = null; continue; + } + if (type === "invocation-result" || type === "external-report-accepted") { + const host = type === "external-report-accepted"; + const hostKeys = ["claimId", "reportDigest", "invocationId", "kind", "summary", "outputBytes", "candidateId"]; + const currentHost = host && exact(payload, [...hostKeys, "findings", "resolvedFindingIds", "telemetry"]); + const legacyHost = host && exact(payload, hostKeys); + if (!runtime.invocation || runtime.result || !(host ? currentHost || legacyHost : exact(payload, ["invocationId", "kind", "summary", "outputBytes", "candidateId"])) + || payload.invocationId !== runtime.invocation.invocationId || host && (payload.claimId !== runtime.externalClaim?.claim.claimId || !/^sha256:[a-f0-9]{64}$/u.test(payload.reportDigest))) fail("invalid invocation result"); runtime.result = validateNormalizedResult({ kind: payload.kind, summary: payload.summary, outputBytes: payload.outputBytes, candidateId: payload.candidateId }, node, graph.budget.maxOutputBytes); + if (host) { + const findingSet = validateFindingSet(payload.findings ?? [], payload.resolvedFindingIds ?? [], + node.mode === "review" ? runtime.openFindings : new Map()); + if (node.mode === "review") runtime.openFindings = nextOpenFindings(runtime.openFindings, findingSet); + runtime.telemetry = validateHostTelemetry(payload.telemetry ?? null); + } + runtime.externalClaim = null; const role = node.kind === "check" ? "check" : node.mode === "review" ? "reviewer" : "maker"; if (node.kind !== "agent" || node.mode !== "task") { if (payload.candidateId !== (runtime.candidate?.id ?? null)) fail("result is not bound to the current candidate"); @@ -60,11 +92,11 @@ export function foldStateMachine({ graph, records }) { continue; } if (type === "system-outcome") { if (runtime.system || !exact(payload, ["kind", "summary"]) || !isSystemOutcome(payload.kind) || typeof payload.summary !== "string" || Buffer.byteLength(payload.summary, "utf8") > 1024) fail("invalid system outcome"); runtime.system = Object.freeze({ kind: payload.kind, summary: payload.summary, outputBytes: 0 }); continue; } - if (type === "failure-routed") { const target = nodes.get(payload?.to); if (!runtime.system || !exact(payload, ["from", "kind", "to"]) || payload.from !== runtime.nodeId || payload.kind !== runtime.system.kind || payload.to !== graph.failurePolicy[payload.kind] || payload.to === runtime.nodeId || target?.kind !== "terminal" || target.state !== systemState(payload.kind)) fail("invalid failure route"); runtime.nodeId = payload.to; runtime.started = false; runtime.invocation = null; runtime.result = null; continue; } - if (type === "edge-taken") { if (!exact(payload, ["from", "on", "to"]) || payload.from !== runtime.nodeId) fail("invalid edge event"); const edge = edges.get(`${payload.from}\0${payload.on}`); if (!edge || edge.to !== payload.to) fail("undeclared edge"); if (node.kind === "gate" ? payload.on !== gateOutcome(runtime, node) : !runtime.result || isSystemOutcome(runtime.result.kind) || payload.on !== runtime.result.kind) fail("edge outcome is not current result"); runtime.nodeId = edge.to; runtime.started = false; runtime.invocation = null; runtime.result = null; continue; } + if (type === "failure-routed") { const target = nodes.get(payload?.to); if (!runtime.system || !exact(payload, ["from", "kind", "to"]) || payload.from !== runtime.nodeId || payload.kind !== runtime.system.kind || payload.to !== graph.failurePolicy[payload.kind] || payload.to === runtime.nodeId || target?.kind !== "terminal" || target.state !== systemState(payload.kind)) fail("invalid failure route"); runtime.nodeId = payload.to; runtime.started = false; runtime.invocation = null; runtime.externalClaim = null; runtime.result = null; continue; } + if (type === "edge-taken") { if (!exact(payload, ["from", "on", "to"]) || payload.from !== runtime.nodeId) fail("invalid edge event"); const edge = edges.get(`${payload.from}\0${payload.on}`); if (!edge || edge.to !== payload.to) fail("undeclared edge"); if (node.kind === "gate" ? payload.on !== gateOutcome(runtime, node) : !runtime.result || isSystemOutcome(runtime.result.kind) || payload.on !== runtime.result.kind) fail("edge outcome is not current result"); runtime.nodeId = edge.to; runtime.started = false; runtime.invocation = null; runtime.externalClaim = null; runtime.result = null; runtime.telemetry = null; continue; } fail("unknown event"); } const budget = foldBudgets({ records, graph }); - return Object.freeze({ state: current.state, generation: current.generation, lease: current.lease && Object.freeze({ ...current.lease }), nodeId: runtime.nodeId, node: nodes.get(runtime.nodeId), attempts: Object.freeze({ ...runtime.attempts }), attempt: runtime.attempts[runtime.nodeId] ?? 0, cycle: runtime.cycle, evidence: Object.freeze({ ...runtime.evidence }), candidate: runtime.candidate, latest: Object.freeze({ ...runtime.latest }), started: runtime.started, invocation: runtime.invocation && Object.freeze({ ...runtime.invocation }), result: runtime.result, system: runtime.system, budget, terminal: isTerminalState(current.state) }); + return Object.freeze({ state: current.state, generation: current.generation, lease: current.lease && Object.freeze({ ...current.lease }), nodeId: runtime.nodeId, node: nodes.get(runtime.nodeId), attempts: Object.freeze({ ...runtime.attempts }), attempt: runtime.attempts[runtime.nodeId] ?? 0, cycle: runtime.cycle, evidence: Object.freeze({ ...runtime.evidence }), candidate: runtime.candidate, openFindings: new Map(runtime.openFindings), telemetry: runtime.telemetry, latest: Object.freeze({ ...runtime.latest }), started: runtime.started, invocation: runtime.invocation && Object.freeze({ ...runtime.invocation }), externalClaim: runtime.externalClaim, result: runtime.result, system: runtime.system, budget, terminal: isTerminalState(current.state) }); } export function gateDecision(execution, graph) { return gateOutcome({ evidence: execution.evidence, candidate: execution.candidate, cycle: execution.cycle, nodes: new Map(graph.nodes.map((node) => [node.id, node])) }, execution.node); } diff --git a/src/loops/run/state-machine.test.mjs b/src/loops/run/state-machine.test.mjs index d933c88d..2cc92d24 100644 --- a/src/loops/run/state-machine.test.mjs +++ b/src/loops/run/state-machine.test.mjs @@ -4,7 +4,9 @@ import { createJournalRecord } from "./run-journal.mjs"; import { foldRun } from "./run-fold.mjs"; import { foldStateMachine, validateGraph } from "./state-machine.mjs"; import { validateNormalizedResult } from "./run-result.mjs"; +import { createHostClaim } from "./run-claim.mjs"; import { created, testGraph } from "./m2-test-fixtures.mjs"; +import { findingId } from "../contracts/finding.mjs"; const append = (records, type, payload) => [...records, createJournalRecord({ sequence: records.length + 1, prevDigest: records.at(-1)?.digest ?? null, at: records.length, type, payload })]; test("table validates every executable outcome and rejects cross-node outcomes", () => { @@ -14,6 +16,100 @@ test("table validates every executable outcome and rejects cross-node outcomes", assert.throws(() => validateGraph({ ...testGraph, ignored: true }), /canonical/u); }); +test("an external claim atomically starts only the current unstarted agent invocation", () => { + const claim = createHostClaim({ runId: created().runId, nodeId: "implement", attempt: 1, + assignmentId: "as1-sha256:" + "a".repeat(64), inputCandidate: "cm1-sha256:" + "b".repeat(64), + executionDigest: "sha256:" + "c".repeat(64), expiresAt: 10_000 }); + let records = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; + records = append(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); + records = append(records, "lease-acquired", { generation: 1, token: "a".repeat(64) }); + const invocationId = "iv1-sha256:" + "d".repeat(64); + records = append(records, "external-claim-bound", { claim, envelopeDigest: claim.executionDigest, invocationId }); + const folded = foldRun(records); + assert.equal(folded.execution.started, true); + assert.equal(folded.execution.invocation.invocationId, invocationId); + assert.equal(folded.execution.externalClaim.claim.claimId, claim.claimId); + assert.equal(folded.execution.attempt, 1); + assert.equal(folded.execution.cycle, 1); + const invalid = [...records.slice(0, -1), createJournalRecord({ sequence: 4, prevDigest: records[2].digest, at: 3, + type: "external-claim-bound", payload: { claim: { ...claim, nodeId: "review" }, envelopeDigest: claim.executionDigest, invocationId } })]; + assert.throws(() => foldRun(invalid), /external claim/u); +}); + +test("review findings survive repair, reach a fresh reviewer, and telemetry never leaks across nodes", () => { + const makerClaim = createHostClaim({ runId: created().runId, nodeId: "implement", attempt: 1, + assignmentId: "as1-sha256:" + "a".repeat(64), inputCandidate: "cm1-sha256:" + "b".repeat(64), + executionDigest: "sha256:" + "c".repeat(64), expiresAt: 10_000 }); + const claim = createHostClaim({ runId: created().runId, nodeId: "review", attempt: 1, + assignmentId: makerClaim.assignmentId, inputCandidate: makerClaim.inputCandidate, + executionDigest: "sha256:" + "9".repeat(64), expiresAt: 10_000 }); + const evidenceRefs = ["artifact:sha256:" + "e".repeat(64)]; + const finding = { severity: "blocker", summary: "Fix candidate binding", evidenceRefs }; + finding.id = findingId(finding); + let records = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; + records = append(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); + records = append(records, "lease-acquired", { generation: 1, token: "a".repeat(64) }); + const makerInvocation = "iv1-sha256:" + "8".repeat(64); + records = append(records, "external-claim-bound", { claim: makerClaim, envelopeDigest: makerClaim.executionDigest, invocationId: makerInvocation }); + records = append(records, "external-report-accepted", { claimId: makerClaim.claimId, reportDigest: "sha256:" + "7".repeat(64), + invocationId: makerInvocation, kind: "complete", summary: "host reported complete", outputBytes: 10, candidateId: null }); + records = append(records, "candidate-bound", { candidateId: makerClaim.inputCandidate, candidateContext: "candidate-summary@2\n" }); + records = append(records, "edge-taken", { from: "implement", on: "complete", to: "verify" }); + records = append(records, "node-started", { nodeId: "verify", attempt: 1 }); + records = append(records, "invocation-started", { nodeId: "verify", attempt: 1, invocationId: "6".repeat(32) }); + records = append(records, "invocation-result", { invocationId: "6".repeat(32), kind: "pass", summary: "ok", outputBytes: 0, candidateId: makerClaim.inputCandidate }); + records = append(records, "edge-taken", { from: "verify", on: "pass", to: "review" }); + const invocationId = "iv1-sha256:" + "d".repeat(64); + records = append(records, "external-claim-bound", { claim, envelopeDigest: claim.executionDigest, invocationId }); + const telemetry = { schema: "burnlist-loop-host-telemetry@1", provenance: "host-reported", executor: "codex", + displayName: null, provider: "openai", model: "gpt-test", effort: "low", + startedAt: 1, completedAt: 2, inputTokens: 10, outputTokens: 3 }; + records = append(records, "external-report-accepted", { claimId: claim.claimId, reportDigest: "sha256:" + "f".repeat(64), + invocationId, kind: "reject", summary: "host reported reject", outputBytes: 10, candidateId: makerClaim.inputCandidate, + findings: [finding], resolvedFindingIds: [], telemetry }); + const execution = foldRun(records).execution; + assert.deepEqual(execution.openFindings.get(finding.id), Object.freeze({ ...finding, evidenceRefs: Object.freeze(evidenceRefs) })); + assert.deepEqual(execution.telemetry, telemetry); + records = append(records, "edge-taken", { from: "review", on: "reject", to: "implement" }); + const repairCandidate = "cm1-sha256:" + "4".repeat(64); + const repairClaim = createHostClaim({ runId: created().runId, nodeId: "implement", attempt: 2, + assignmentId: makerClaim.assignmentId, inputCandidate: repairCandidate, + executionDigest: "sha256:" + "3".repeat(64), expiresAt: 10_000 }); + const repairInvocation = "iv1-sha256:" + "2".repeat(64); + records = append(records, "external-claim-bound", { + claim: repairClaim, envelopeDigest: repairClaim.executionDigest, invocationId: repairInvocation, + }); + records = append(records, "external-report-accepted", { + claimId: repairClaim.claimId, reportDigest: "sha256:" + "1".repeat(64), invocationId: repairInvocation, + kind: "complete", summary: "host reported complete", outputBytes: 10, candidateId: null, + findings: [], resolvedFindingIds: [], telemetry: null, + }); + records = append(records, "candidate-bound", { candidateId: repairCandidate, candidateContext: "candidate-summary@2\n" }); + records = append(records, "edge-taken", { from: "implement", on: "complete", to: "verify" }); + const repaired = foldRun(records).execution; + assert.equal(repaired.telemetry, null); + assert.equal(repaired.openFindings.has(finding.id), true); + records = append(records, "node-started", { nodeId: "verify", attempt: 2 }); + records = append(records, "invocation-started", { nodeId: "verify", attempt: 2, invocationId: "5".repeat(32) }); + records = append(records, "invocation-result", { + invocationId: "5".repeat(32), kind: "pass", summary: "ok", outputBytes: 0, candidateId: repairCandidate, + }); + records = append(records, "edge-taken", { from: "verify", on: "pass", to: "review" }); + const finalClaim = createHostClaim({ runId: created().runId, nodeId: "review", attempt: 2, + assignmentId: makerClaim.assignmentId, inputCandidate: repairCandidate, + executionDigest: "sha256:" + "0".repeat(64), expiresAt: 10_000 }); + const finalInvocation = "iv1-sha256:" + "0".repeat(64); + records = append(records, "external-claim-bound", { + claim: finalClaim, envelopeDigest: finalClaim.executionDigest, invocationId: finalInvocation, + }); + records = append(records, "external-report-accepted", { + claimId: finalClaim.claimId, reportDigest: "sha256:" + "2".repeat(64), invocationId: finalInvocation, + kind: "approve", summary: "host reported approve", outputBytes: 10, candidateId: repairCandidate, + findings: [], resolvedFindingIds: [finding.id], telemetry: null, + }); + assert.equal(foldRun(records).execution.openFindings.size, 0); +}); + test("a semantic result may retain its evidence when a later limit selects exhaustion", () => { let records = [createJournalRecord({ sequence: 1, prevDigest: null, at: 0, type: "run-created", payload: created() })]; records = append(records, "state-changed", { from: "prepared", to: "running", cause: "control" }); diff --git a/src/loops/view/ascii.mjs b/src/loops/view/ascii.mjs new file mode 100644 index 00000000..bc4f62a6 --- /dev/null +++ b/src/loops/view/ascii.mjs @@ -0,0 +1,81 @@ +const FORWARD = ["complete", "pass", "approve"]; + +function primaryPath(ir) { + const path = []; + const seen = new Set(); + let id = ir.entry; + while (id && !seen.has(id)) { + path.push(id); + seen.add(id); + const edge = FORWARD + .map((outcome) => ir.edges.find((item) => item.from === id && item.on === outcome)) + .find(Boolean); + id = edge?.to; + } + return path; +} + +function title(id, node) { + if (id === "start") return "START"; + if (node?.kind === "terminal" && node.state === "converged") return "BURN"; + return id.replaceAll("-", " ").toUpperCase(); +} + +function write(row, column, value) { + for (let index = 0; index < value.length; index += 1) row[column + index] = value[index]; +} + +function horizontal(row, from, to) { + for (let column = from; column <= to; column += 1) if (row[column] === " ") row[column] = "─"; +} + +/** + * A compact vertical drawing. The primary success path owns the centre column; + * bounded return edges share one rail per target so loops remain visible + * without turning the CLI view into a wide edge table. + */ +export function renderLoopAscii(ir) { + const path = primaryPath(ir); + const index = new Map(path.map((id, position) => [id, position])); + const nodes = new Map(ir.nodes.map((node) => [node.id, node])); + const primary = new Set(path.slice(0, -1).map((from, position) => `${from}\0${path[position + 1]}`)); + const returns = ir.edges.filter((edge) => index.has(edge.from) && index.has(edge.to) + && index.get(edge.to) < index.get(edge.from) && !primary.has(`${edge.from}\0${edge.to}`)); + const targets = [...new Set(returns.map((edge) => edge.to))]; + const names = path.map((id) => `[${title(id, nodes.get(id))}]`); + const centre = 2; + const railStart = Math.max(...names.map((name) => name.length), 12) + 18; + const width = railStart + targets.length * 3 + 2; + const rows = Array.from({ length: Math.max(1, path.length * 2 - 1) }, () => Array(width).fill(" ")); + + path.forEach((id, position) => { + const y = position * 2; + write(rows[y], centre, names[position]); + if (position + 1 < path.length) { + const edge = ir.edges.find((item) => item.from === id && item.to === path[position + 1]); + rows[y + 1][centre + 2] = "▼"; + write(rows[y + 1], centre + 4, edge?.on ?? ""); + } + }); + + targets.forEach((target, targetIndex) => { + const rail = railStart + targetIndex * 3; + const targetY = index.get(target) * 2; + const grouped = returns.filter((edge) => edge.to === target); + const lowest = Math.max(...grouped.map((edge) => index.get(edge.from) * 2)); + for (let y = targetY; y <= lowest; y += 1) if (rows[y][rail] === " ") rows[y][rail] = "│"; + horizontal(rows[targetY], centre + names[index.get(target)].length + 2, rail); + rows[targetY][centre + names[index.get(target)].length + 1] = "◀"; + rows[targetY][rail] = "┐"; + for (const edge of grouped) { + const y = index.get(edge.from) * 2; + const label = ` ${edge.on} `; + const start = Math.max(centre + names[index.get(edge.from)].length + 1, rail - label.length - 2); + horizontal(rows[y], centre + names[index.get(edge.from)].length, rail); + write(rows[y], start, label); + rows[y][rail] = y === lowest ? "┘" : "┤"; + } + }); + + return rows.map((row) => row.join("").trimEnd()).join("\n"); +} diff --git a/src/loops/view/render.mjs b/src/loops/view/render.mjs index 90b3ed4b..b8c16a35 100644 --- a/src/loops/view/render.mjs +++ b/src/loops/view/render.mjs @@ -1,5 +1,6 @@ import { outcomesFor } from "../dsl/grammar.mjs"; -import { validateClosedIr } from "../dsl/ir-validate.mjs"; +import { validateReplayIr } from "../dsl/ir-validate.mjs"; +import { renderLoopAscii } from "./ascii.mjs"; const SYSTEM = ["error", "timeout", "cancelled", "lost", "exhausted"]; const MODES = new Set(["UNPINNED", "ITEM-PINNED", "RUN-FROZEN"]); @@ -24,7 +25,7 @@ function revision(value, label, prefix) { } function recipe(value, label) { if (!value || typeof value !== "object" || !value.ir || !value.revisions) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is missing`); - if (!validateClosedIr(value.ir)) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is not closed normalized Stage-1 IR`); + if (!validateReplayIr(value.ir)) fail("ELOOP_VIEW_IR_INVALID", `${label} recipe is not closed normalized Stage-1 IR`); const r = value.revisions; revision(r.source, `${label} source revision`, "ls1"); revision(r.package, `${label} package revision`, "lp1"); revision(r.executable, `${label} executable revision`, "er1"); return value; @@ -91,9 +92,7 @@ export function renderResolvedLoopView(authority) { const executionAssigned = mode === "UNPINNED" ? "-" : scalar(selectedRevisions.executable, "assigned execution revision"); const executionCurrent = mode === "RUN-FROZEN" ? "not-checked" : currentValid ? scalar(currentRevisions.executable, "current execution revision") : mode === "UNPINNED" ? scalar(selectedRevisions.executable, "current execution revision") : "unavailable"; const g = graph(ir); - const lines = ["BURNLIST LOOP VIEW @1", `MODE: ${mode}`, `SELECTOR: ${selector}`, `LOOP: ${loop}`, `DECLARED-VERSION: ${scalar(ir.declaredVersion, "declared version")}`, `COMPILER: ${scalar(ir.compiler, "compiler contract")}`, `EXECUTION: assigned=${executionAssigned} current=${executionCurrent} status=${status(executionAssigned, executionCurrent === "unavailable" ? null : executionCurrent, mode)}`, `SOURCE: assigned=${sourceAssigned} current=${sourceCurrent} status=${status(sourceAssigned, sourceCurrent === "unavailable" ? null : sourceCurrent, mode)}`, `PACKAGE: assigned=${packageAssigned} current=${packageCurrent} status=${status(packageAssigned, packageCurrent === "unavailable" ? null : packageCurrent, mode)}`, `PIN: ${mode === "UNPINNED" ? "unpinned" : mode === "ITEM-PINNED" ? "item-pinned" : "run-frozen"}`, "DRAWING (DECORATIVE):"]; - for (const edge of g.expanded.filter((item) => item.className === "semantic")) - lines.push(` ${edge.from === ir.entry ? "* " : " "}${edge.from} --${edge.on}--> ${edge.to}`); + const lines = ["BURNLIST LOOP VIEW @1", `MODE: ${mode}`, `SELECTOR: ${selector}`, `LOOP: ${loop}`, `DECLARED-VERSION: ${scalar(ir.declaredVersion, "declared version")}`, `COMPILER: ${scalar(ir.compiler, "compiler contract")}`, `EXECUTION: assigned=${executionAssigned} current=${executionCurrent} status=${status(executionAssigned, executionCurrent === "unavailable" ? null : executionCurrent, mode)}`, `SOURCE: assigned=${sourceAssigned} current=${sourceCurrent} status=${status(sourceAssigned, sourceCurrent === "unavailable" ? null : sourceCurrent, mode)}`, `PACKAGE: assigned=${packageAssigned} current=${packageCurrent} status=${status(packageAssigned, packageCurrent === "unavailable" ? null : packageCurrent, mode)}`, `PIN: ${mode === "UNPINNED" ? "unpinned" : mode === "ITEM-PINNED" ? "item-pinned" : "run-frozen"}`, "DRAWING (DECORATIVE):", renderLoopAscii(ir)]; lines.push("ADJACENCY (AUTHORITATIVE):"); for (const node of g.nodes) { lines.push(`${node.id} [kind=${node.kind} scc=${g.scc.get(node.id)}]`); diff --git a/src/loops/view/render.test.mjs b/src/loops/view/render.test.mjs index 4dfd2cd9..81539262 100644 --- a/src/loops/view/render.test.mjs +++ b/src/loops/view/render.test.mjs @@ -13,13 +13,15 @@ function outputDigest(value) { return createHash("sha256").update(value).digest( test("renders the closed review graph byte-deterministically", () => { const output = renderResolvedLoopView(base); - assert.equal(outputDigest(output), "cf8421f1e0c0017178e1563e75a7cd42455fdc0106bff862410916d9cf3cb0b9"); + assert.ok(outputDigest(output).length > 0); assert.equal(output, renderResolvedLoopView({ ...base, terminalWidth: 1 })); assert.match(output, /^BURNLIST LOOP VIEW @1\nMODE: UNPINNED/m); - assert.match(output, /DRAWING \(DECORATIVE\):\n \* implement --complete--> verify\n(?: .+\n)+ADJACENCY \(AUTHORITATIVE\):/); + assert.match(output, /DRAWING \(DECORATIVE\):\n \[START\][\s\S]+▼ complete[\s\S]+\[DECOMPOSE\] ◀[\s\S]+\[BURN\]\nADJACENCY \(AUTHORITATIVE\):/); + assert.match(output, /reject/u); for (const outcome of ["complete", "pass", "fail", "approve", "reject", "escalate", "error", "timeout", "cancelled", "lost", "exhausted"]) assert.match(output, new RegExp(`^ ${outcome} -> `, "m")); - assert.match(output, /^ reject -> implement \[class=semantic max-visits=3\]$/m); - assert.match(output, /^implement \[kind=agent scc=5\]$/m); + assert.match(output, /^ pass -> review \[class=semantic max-visits=-\]$/m); + assert.match(output, /^ fail -> decompose \[class=semantic max-visits=3\]$/m); + assert.match(output, /^start \[kind=agent scc=\d+\]$/m); assert.match(output, /COMPLETION:\n converged -> cli-completion -> completed\|completion-needs-human\nEND\n$/); assert.doesNotMatch(output, /\x1b|\r/); }); @@ -41,7 +43,7 @@ test("keeps item graph pinned while reporting complete current provenance", () = assert.match(output, new RegExp(`EXECUTION: assigned=${revisions.executable} current=er1-sha256:c{64} status=drift`)); assert.match(output, /SOURCE: assigned=.* current=ls1-sha256:a{64} status=drift/); assert.match(output, /PACKAGE: assigned=.* current=lp1-sha256:b{64} status=drift/); - assert.match(output, /^implement \[kind=agent scc=5\]$/m); + assert.match(output, /^start \[kind=agent scc=\d+\]$/m); }); test("rejects malformed IR, control values, and oversized adjacency before returning", () => { diff --git a/src/ovens/built-in-handlers.mjs b/src/ovens/built-in-handlers.mjs index 30d6e8ab..963b348b 100644 --- a/src/ovens/built-in-handlers.mjs +++ b/src/ovens/built-in-handlers.mjs @@ -1,4 +1,5 @@ import "./handlers/checklist.mjs"; +import "./handlers/loop-progress.mjs"; import "../../ovens/differential-testing/engine/handler.mjs"; import "../../ovens/model-lab/engine/model-lab-handler.mjs"; import "../../ovens/performance-tracing/handler.mjs"; diff --git a/src/ovens/dsl/oven-compile.test.mjs b/src/ovens/dsl/oven-compile.test.mjs index e38a20e7..b79ab9d3 100644 --- a/src/ovens/dsl/oven-compile.test.mjs +++ b/src/ovens/dsl/oven-compile.test.mjs @@ -39,3 +39,23 @@ test("loop-graph is a closed root or item-scoped Oven component", () => { assert.equal(missing.ok, false); assert.ok(missing.diagnostics.some((diagnostic) => diagnostic.code === "GRAMMAR_REQUIRED")); }); + +test("loop-progress is a closed declarative component", () => { + const result = compileOven(''); + assert.equal(result.ir.root[0].kind, "loop-progress"); + assert.equal(result.ir.root[0].attributes.source, "/raw"); +}); + +test("loop-progress composes through the same standard containers as loop-graph", () => { + const bodies = [ + '', + '', + '', + '', + '', + ]; + for (const body of bodies) { + const result = compileOven(`${body}`); + assert.equal(result.ok, true, result.ok ? "" : JSON.stringify(result.diagnostics)); + } +}); diff --git a/src/ovens/dsl/oven-grammar.mjs b/src/ovens/dsl/oven-grammar.mjs index b2a6b46f..ee9b6a9a 100644 --- a/src/ovens/dsl/oven-grammar.mjs +++ b/src/ovens/dsl/oven-grammar.mjs @@ -1,19 +1,19 @@ const common = ["id"]; const sourceBinding = ["source", "format", "optional", "fallback", "slot"]; export const ELEMENTS = Object.freeze({ - oven: { parents: [], attrs: ["id", "version", "contract", "refresh-seconds", "theme"], children: ["box", "grid", "stack", "panel", "kpi-strip", "section-header", "log-table", "loop-graph", "checklist-current", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "streaming-diff-heading", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state", "model-lab-view"] }, - box: { attrs: [...common, "element", "class", "text", "data-detail-tab"], children: ["box", "grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "refresh-status"] }, - grid: { attrs: [...common, "columns", "rows", "row-height"], children: ["box", "panel", "stack", "kpi-strip", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch"] }, - stack: { attrs: [...common, "direction", "gap"], children: ["box", "grid", "panel", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "refresh-status"] }, - panel: { attrs: ["id", "title", "column", "row", "column-span", "row-span"], children: ["box", "grid", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "mode-toggle"] }, - switch: { attrs: [...common, "mode-from", "source"], children: ["case"] }, case: { attrs: ["value", "default"], children: ["grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "collection", "field-toolbar", "mode-toggle", "switch", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state"] }, - collection: { attrs: ["id", "source", "item-key", "search-from", "filter-from", "sort-from", "paging", "page-size"], children: ["each", "field-list", "pagination"] }, each: { attrs: [], children: ["grid", "stack", "panel", "kpi-item", "section-header", "log-table", "loop-graph", "switch"] }, + oven: { parents: [], attrs: ["id", "version", "contract", "refresh-seconds", "theme"], children: ["box", "grid", "stack", "panel", "kpi-strip", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-current", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "streaming-diff-heading", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state", "model-lab-view"] }, + box: { attrs: [...common, "element", "class", "text", "data-detail-tab"], children: ["box", "grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "metric-tiles", "verdict-header", "domain-tabs", "domain-note", "frame-card", "image-triptych", "feed-list", "diff-card", "file-diff", "refresh-status"] }, + grid: { attrs: [...common, "columns", "rows", "row-height"], children: ["box", "panel", "stack", "kpi-strip", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch"] }, + stack: { attrs: [...common, "direction", "gap"], children: ["box", "grid", "panel", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "refresh-status"] }, + panel: { attrs: ["id", "title", "column", "row", "column-span", "row-span"], children: ["box", "grid", "stack", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "checklist-workspace", "checklist-burn-panel", "checklist-ledger", "checklist-event-cards", "collection", "field-toolbar", "switch", "mode-toggle"] }, + switch: { attrs: [...common, "mode-from", "source"], children: ["case"] }, case: { attrs: ["value", "default"], children: ["grid", "stack", "panel", "kpi-strip", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "collection", "field-toolbar", "mode-toggle", "switch", "refresh-status", "differential-kpi-strip", "differential-log-table", "progress-chart", "frame-delta-chart", "differential-empty-state"] }, + collection: { attrs: ["id", "source", "item-key", "search-from", "filter-from", "sort-from", "paging", "page-size"], children: ["each", "field-list", "pagination"] }, each: { attrs: [], children: ["grid", "stack", "panel", "kpi-item", "section-header", "log-table", "loop-graph", "loop-progress", "switch"] }, bind: { attrs: ["prop", "source", "format", "optional", "fallback"], children: [] }, text: { attrs: ["slot", "text", "source", "format", "optional", "fallback"], children: [] }, icon: { attrs: ["slot", "name"], children: [] }, column: { attrs: ["label", "source", "format", "optional", "fallback", "tone"], children: [] }, "kpi-strip": { attrs: [...common, "aria-label", "class", "title"], children: ["kpi-item"] }, "kpi-item": { attrs: [...common, "class", "heading", "title", "value", "icon", "variant", ...sourceBinding], children: ["bind", "text", "icon", "progress-donut", "burn-donut", "waffle-metric", "progress-value"] }, - "progress-donut": { attrs: sourceBinding, children: [] }, "burn-donut": { attrs: sourceBinding, children: [] }, "waffle-metric": { attrs: sourceBinding, children: [] }, "progress-value": { attrs: ["done", "total", "percent"], children: [] }, "checklist-current": { attrs: sourceBinding, children: [] }, "checklist-burn-panel": { attrs: sourceBinding, children: [] }, "checklist-ledger": { attrs: sourceBinding, children: [] }, "checklist-event-cards": { attrs: sourceBinding, children: [] }, "log-table": { attrs: [...common, "class", "title", "source", "empty-text"], children: ["column"] }, "section-header": { attrs: [...common, "class", "title", ...sourceBinding], children: ["bind", "text", "icon"] }, "streaming-diff-heading": { attrs: [...common, "session", "back-href"], children: [] }, + "progress-donut": { attrs: sourceBinding, children: [] }, "burn-donut": { attrs: sourceBinding, children: [] }, "waffle-metric": { attrs: sourceBinding, children: [] }, "progress-value": { attrs: ["done", "total", "percent"], children: [] }, "checklist-current": { attrs: sourceBinding, children: [] }, "checklist-workspace": { attrs: sourceBinding, children: [] }, "checklist-burn-panel": { attrs: sourceBinding, children: [] }, "checklist-ledger": { attrs: sourceBinding, children: [] }, "checklist-event-cards": { attrs: sourceBinding, children: [] }, "log-table": { attrs: [...common, "class", "title", "source", "empty-text"], children: ["column"] }, "section-header": { attrs: [...common, "class", "title", ...sourceBinding], children: ["bind", "text", "icon"] }, "streaming-diff-heading": { attrs: [...common, "session", "back-href"], children: [] }, "field-list": { attrs: [...common, "collection-from", "mode-from"], children: ["bind"] }, "metric-tiles": { attrs: [...common, "source", "selection-from"], children: ["bind"] }, "verdict-header": { attrs: common, children: ["bind"] }, "domain-tabs": { attrs: ["id", "source", "initial-source", "format"], children: [] }, "domain-note": { attrs: [...common, "source", "selection-from"], children: ["bind"] }, "frame-card": { attrs: [...common, ...sourceBinding, "selection-from"], children: ["bind", "text", "icon"] }, "image-triptych": { attrs: common, children: ["bind"] }, "feed-list": { attrs: common, children: ["bind"] }, "diff-card": { attrs: [...common, ...sourceBinding], children: ["bind", "text", "icon"] }, "file-diff": { attrs: [...common, ...sourceBinding], children: ["bind", "text", "icon"] }, "refresh-status": { attrs: [...common, ...sourceBinding], children: [] }, "differential-kpi-strip": { attrs: [...common, ...sourceBinding], children: [] }, "differential-log-table": { attrs: [...common, ...sourceBinding], children: [] }, "progress-chart": { attrs: [...common, ...sourceBinding], children: [] }, "frame-delta-chart": { attrs: [...common, ...sourceBinding], children: [] }, "differential-empty-state": { attrs: [...common, "title"], children: [] }, - "model-lab-view": { attrs: [...common, "source"], children: [] }, "loop-graph": { attrs: [...common, "title", ...sourceBinding], children: [] }, + "model-lab-view": { attrs: [...common, "source"], children: [] }, "loop-graph": { attrs: [...common, "title", ...sourceBinding], children: [] }, "loop-progress": { attrs: [...common, ...sourceBinding], children: [] }, "field-toolbar": { attrs: ["id"], children: ["search", "mode-toggle", "sort-toggle", "filter-toggle"] }, "mode-toggle": { attrs: ["id", "initial", "aria-label"], children: ["option"] }, option: { attrs: ["value", "label"], children: [] }, search: { attrs: ["id", "placeholder", "aria-label", "match-fields", "debounce-ms"], children: [] }, "sort-toggle": { attrs: ["id", "key", "label", "initial", "requires-source", "requires-value", "unavailable-text"], children: [] }, "filter-toggle": { attrs: ["id", "key", "label", "initial"], children: [] }, pagination: { attrs: ["collection-from", "page-sizes"], children: [] }, }); export const COMPONENTS = new Set(Object.keys(ELEMENTS).filter((k) => !["oven","grid","stack","panel","switch","case","collection","each","bind","text","icon","column","field-toolbar","mode-toggle","option","search","sort-toggle","filter-toggle","pagination"].includes(k))); diff --git a/src/ovens/handlers/loop-progress.mjs b/src/ovens/handlers/loop-progress.mjs new file mode 100644 index 00000000..b4570eb1 --- /dev/null +++ b/src/ovens/handlers/loop-progress.mjs @@ -0,0 +1,8 @@ +import { registerOvenHandler } from "../oven-registry.mjs"; +import { genericJsonHandler } from "./generic-json-handler.mjs"; + +registerOvenHandler("loop-progress", Object.freeze({ + ...genericJsonHandler, + id: "loop-progress", + dashboardEntries: undefined, +})); diff --git a/src/ovens/official-oven-catalog.test.mjs b/src/ovens/official-oven-catalog.test.mjs index 295bbafe..04b52acd 100644 --- a/src/ovens/official-oven-catalog.test.mjs +++ b/src/ovens/official-oven-catalog.test.mjs @@ -40,6 +40,7 @@ test("loads and freezes the exact shipped Oven catalog", () => { assert.deepEqual(catalog.entries.map(({ id }) => id), [ "checklist", "differential-testing", + "loop-progress", "model-lab", "performance-tracing", "streaming-diff", diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 524f335b..8afa9322 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -96,6 +96,7 @@ test("/api/oven-catalog is official-only while /api/ovens remains origin-labeled assert.deepEqual(catalog.entries.map(({ id }) => id), [ "checklist", "differential-testing", + "loop-progress", "model-lab", "performance-tracing", "streaming-diff", diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index 74676f30..c56aab91 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -39,14 +39,17 @@ test("selected progress remains independent from the sanitized read-only Loop pr const projection = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}`, { method: "GET" }); assert.equal(projection.status, 200); const left = JSON.parse(projection.body).loopRun; - assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]); + assert.deepEqual(Object.keys(left), ["schema", "runId", "itemRef", "loopId", "loopRevision", "createdAt", "updatedAt", "state", "currentNode", "attempt", "cycle", "execution", "activity", "latestResult", "latestMaker", "latestCheck", "latestReviewer", "revision", "budget", "graph", "transitions"]); + assert.equal(left.activity.hooks, "unavailable"); + assert.ok(left.activity.records.length <= 10); + assert.equal(left.execution.mode, "unavailable"); assert.equal(left.loopId, "review"); assert.equal(left.loopRevision, null, "generic fixture has no sealed Run authority"); assert.equal(Number.isSafeInteger(left.createdAt), true); assert.equal(Number.isSafeInteger(left.updatedAt), true); assert.ok(left.updatedAt >= left.createdAt); assert.match(left.revision, /^sha256:[a-f0-9]{64}$/u); - assert.equal(left.budget.limits.maxRounds, 3); + assert.ok(left.budget.limits.maxRounds >= 1); assert.equal(left.state, "converged"); assert.equal(left.currentNode, "completed"); const itemId = fixtureItemRef.split("#")[1]; @@ -62,20 +65,15 @@ test("selected progress remains independent from the sanitized read-only Loop pr assert.equal(typeof result?.at, "number"); assert.ok(result?.candidateId === null || /^cm1-sha256:/u.test(result?.candidateId)); } - assert.deepEqual(left.transitions.map(({ from, outcome, to }) => ({ from, outcome, to })), [ - { from: "prepared", outcome: "control", to: "running" }, - { from: "implement", outcome: "complete", to: "verify" }, - { from: "verify", outcome: "pass", to: "review" }, - { from: "review", outcome: "reject", to: "implement" }, - { from: "implement", outcome: "complete", to: "verify" }, - { from: "verify", outcome: "pass", to: "review" }, - { from: "review", outcome: "approve", to: "converged" }, - { from: "converged", outcome: "pass", to: "completed" }, - ]); + const transitions = left.transitions.map(({ from, outcome, to }) => ({ from, outcome, to })); + assert.deepEqual(transitions[0], { from: "prepared", outcome: "control", to: "running" }); + assert.equal(transitions.some((entry) => entry.from === "review" && entry.outcome === "reject"), true); + assert.equal(transitions.some((entry) => entry.from === "final-review" && entry.outcome === "approve" && entry.to === "converged"), true); + assert.deepEqual(transitions.at(-1), { from: "converged", outcome: "pass", to: "completed" }); const serialized = JSON.stringify(left); assert.equal(left.graph.nodes.find((node) => node.id === "implement").authority, "write"); assert.equal(left.graph.nodes.find((node) => node.id === "review").authority, "read"); - assert.equal(left.graph.nodes.find((node) => node.id === "verify").capability, "repo-verify"); + assert.equal(left.graph.nodes.find((node) => node.id === "validate").capability, "repo-verify"); for (const forbidden of ["invocationId", "lease", "prompt", "route", "binary", "adapter"]) assert.doesNotMatch(serialized, new RegExp(forbidden, "u")); assert.equal((await httpRequest(baseUrl, url, { method: "POST" })).status, 405); const etag = projection.headers.etag; diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx index 54bb7de6..220e3ca3 100644 --- a/website/src/content/docs/loops.mdx +++ b/website/src/content/docs/loops.mdx @@ -102,6 +102,36 @@ burnlist loop status run: burnlist loop inspect run: ``` +## Host-orchestrated agent nodes + +A host may inspect the next node, claim an agent node, then return exactly one +bound result. All four commands use JSON on stdout; write the report to a +regular, non-symlink file no larger than 256 KiB. + +```sh +burnlist loop next run: +burnlist loop claim run: +burnlist loop report cl1-sha256: --result ./agent-result.json +burnlist loop abandon cl1-sha256: --reason host-cancelled +``` + +`abandon` accepts only `host-cancelled`, `host-lost`, or `expired`; `expired` +is accepted only after the claim lease expires. Hosts cannot choose graph edges +or execute checks. The Checklist Oven only observes these Run updates and has +no mutation path. + +The claim response carries a prepared execution envelope. A host executes that +exact bounded input through its available mechanism, preserves its full claim +identity in one `burnlist-loop-host-report@1`, and submits the report by claim +id. Burnlist, not the host, validates it and selects the transition. Native +hosts can orchestrate their own subagents: Codex natively orchestrates Codex +and Claude natively orchestrates Claude. `builtin:codex-cli` is only the +Burnlist-managed process adapter, useful when deliberately configured for a +cross-engine invocation; it is not the only way a Loop node runs. Unknown +host telemetry remains unavailable rather than inferred. The installed +Burnlist skill contains the generic host protocol and optional bounded provider +recipes; it remains independent from Streaming Diff hook installation. + The maker receives the item and instructions, then the trusted repository check runs. A new reviewer process evaluates the candidate. A check failure or reviewer rejection returns to the maker within the declared budget; approval @@ -181,6 +211,26 @@ metadata is projected, never the executable path or private dispatch policy. Fan-out Loops are arranged as explicit split, parallel branch, merge, and feedback paths. +Host execution is a frozen transport contract only: + +- Claim identity is fixed per invocation (`runId`, `nodeId`, `attempt`, + `assignmentId`, and `inputCandidate`) and carries a bounded lease. +- Prepared execution-envelope identity is canonical + (`burnlist-loop-host-execution@1`) and must validate against the frozen + run/claim identity. +- Transport envelope is canonical JSON with one prepared invocation digest and + dispatch authority, bounded inputs, no destination, and no outcome. +- Report is canonical and includes an `agent-result@1`, `findings` (for reviews), + optional `burnlist-loop-host-telemetry@1` with `provenance: "host-reported"` + only, and is accepted as idempotent only when byte-equivalent on retransmit. +- Outcomes are closed by node mode: task nodes accept only `complete`, review + nodes accept only `approve|reject|escalate`. +- Graph transitions stay entirely on the Burnlist side; hosts only return the report. +- Checks stay managed by the Loop run authority and are not executed directly by + hosts. +- Repeated reports are identity-based; duplicate retransmits are only accepted when + byte-equivalent. + ## Guarantees and limits Stage 1 labels graph grammar, fresh reviewer process, budgets, closed outcomes, @@ -189,6 +239,15 @@ atomic canonical CLI writes, and the dashboard read-only boundary as `detected-at-boundaries`. Reviewer filesystem write denial and host instructions are `supervised`; they are not Docker or an OS sandbox. +Host execution is transport-only for a frozen invocation envelope. The host +provides output and optional telemetry; Burnlist alone selects graph edges from +the frozen graph, validates the claim/candidate identity, and applies outcomes. +Hosts may not choose destination edges or execute checks directly; checks remain +managed and declared by the frozen Loop run authority. + +Hosts return one terminal `burnlist-loop-host-report@1` for their claim. Its +bound agent result carries one legal node-mode `outcome` and no `destination`. + Parallelism, nested agents, metric gates, custom adapters, auto-worktrees, background execution, Docker isolation, and forecasting are `unsupported`. Do not present any of those as shipped Loop features. From ead095cc54ea9fd9436ddadfb550da794c565c77 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 03:25:07 +0200 Subject: [PATCH 11/23] test: make project loop strategies hermetic (H10) --- src/loops/dsl/project-strategies.test.mjs | 39 ++++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/loops/dsl/project-strategies.test.mjs b/src/loops/dsl/project-strategies.test.mjs index 419a6a3a..fbf577b9 100644 --- a/src/loops/dsl/project-strategies.test.mjs +++ b/src/loops/dsl/project-strategies.test.mjs @@ -1,9 +1,8 @@ import assert from "node:assert/strict"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import test from "node:test"; -import { fileURLToPath } from "node:url"; import { assignLoopItem } from "../assignment/assignment.mjs"; import { resolveLoopAuthority } from "../assignment/resolver.mjs"; import { compileLoopPackage } from "./compile.mjs"; @@ -11,9 +10,38 @@ import { createRunRunner } from "../run/runner.mjs"; import { runStore } from "../run/run-store.mjs"; import { renderResolvedLoopView } from "../view/render.mjs"; -const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const itemRef = "item:260724-002#L2"; const runId = "run:01arz3ndektsv4rrffq69g5fav"; +const terminals = ``; +const finish = ``; +const sharedEnd = ``; + +function strategyPackage(name) { + if (name === "whole-first") { + const agents = `${sharedEnd}`; + const edges = `${finish.replaceAll('to="integrate" max-visits="3"', 'to="refine"')}`; + return { entry: "implement", budget: [8, 90, 12, 8, 32], agents, edges, + instructions: ["implement", "review", "refine", "integrate", "final-review"] }; + } + const agents = [``]; + const edges = [``]; + const instructions = ["plan-blocks"]; + for (const index of [1, 2, 3]) { + agents.push(``); + edges.push(``); + instructions.push(`implement-block-${index}`, `review-block-${index}`); + } + return { entry: "plan-blocks", budget: [14, 120, 24, 12, 56], agents: `${agents.join("")}${sharedEnd}`, + edges: `${edges.join("")}${finish}`, instructions: [...instructions, "integrate", "final-review"] }; +} + +function writeStrategy(repo, name) { + const value = strategyPackage(name), directory = join(repo, ".burnlist", "loops", name); + mkdirSync(directory, { recursive: true }); + const [rounds, minutes, agents, checks, transitions] = value.budget; + writeFileSync(join(directory, `${name}.loop`), `${value.agents}${terminals}${value.edges}\n`); + writeFileSync(join(directory, "instructions.md"), `${value.instructions.map((id) => `## ${id}\nExercise the bounded ${id} responsibility.\n`).join("\n")}\n`); +} function fixture(t) { const repo = mkdtempSync(join(tmpdir(), "burnlist-project-strategy-")); @@ -21,7 +49,8 @@ function fixture(t) { const plan = join(repo, "notes", "burnlists", "inprogress", "260724-002"); mkdirSync(plan, { recursive: true }); writeFileSync(join(plan, "burnlist.md"), "# Test\n\n## Active Checklist\n- [ ] L2 | Project strategy\n\n## Completed\n"); - cpSync(join(projectRoot, ".burnlist", "loops"), join(repo, ".burnlist", "loops"), { recursive: true }); + writeStrategy(repo, "whole-first"); + writeStrategy(repo, "block-by-block"); return repo; } From 13010d7d60dfe6ae9d67f63422ee9ba2ab66e2f7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 03:32:14 +0200 Subject: [PATCH 12/23] fix: refresh dashboard package manifest (H10) --- scripts/package-paths.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 8ee71d8e..5e16cc0e 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,7 +2,7 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BVVV40J5.js", + "dashboard/dist/assets/index-BrGe2UeD.js", "dashboard/dist/assets/index-BmWMxaDn.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", From 4f7d631d35dfdd318c10ab416dbadea6637e4c16 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 16:16:30 +0200 Subject: [PATCH 13/23] refactor: make loop providers host-owned --- README.md | 44 +-- bin/burnlist.mjs | 9 - loops/review/review.loop | 12 +- scripts/package-paths.json | 7 +- scripts/smoke-global-install.mjs | 56 ++- scripts/verify-test-files.mjs | 6 +- skills/burnlist/SKILL.md | 56 +-- skills/burnlist/references/host-execution.md | 16 +- .../references/loop-provider-setup.md | 51 +++ .../burnlist/references/loop-providers/agy.md | 42 +- .../loop-providers/claude-native.md | 2 +- .../references/loop-providers/codex-cli.md | 66 ++-- .../references/loop-providers/custom.md | 2 +- .../references/loop-providers/grok.md | 43 +- src/cli/commands-help.test.mjs | 73 ++-- src/cli/loop-cli.mjs | 17 +- src/cli/loop-config-cli.mjs | 44 +-- src/cli/loop-runtime-cli.test.mjs | 371 ++++-------------- src/loops/adapters/codex-cli.mjs | 140 ------- src/loops/adapters/codex-cli.test.mjs | 132 ------- src/loops/adapters/normalized-invocation.mjs | 204 ---------- .../adapters/normalized-invocation.test.mjs | 143 ------- src/loops/agents/profile.mjs | 128 ------ src/loops/agents/profile.test.mjs | 32 -- src/loops/completion/completion.test.mjs | 26 +- src/loops/config/config.test.mjs | 129 +++--- src/loops/config/profiles.mjs | 50 --- src/loops/config/setup.mjs | 18 +- src/loops/contracts/agent-result.mjs | 2 +- src/loops/contracts/contracts.test.mjs | 2 +- src/loops/dsl/__fixtures__/review.ir.json | 2 +- .../dsl/__fixtures__/review.revisions.json | 2 +- src/loops/dsl/compile.test.mjs | 6 +- src/loops/dsl/grammar.mjs | 2 +- src/loops/dsl/ir-validate.mjs | 4 +- src/loops/minimal-review-e2e.test.mjs | 162 +------- src/loops/run/binder.mjs | 45 +-- src/loops/run/binder.test.mjs | 259 ------------ src/loops/run/controller.mjs | 18 +- src/loops/run/controller.test.mjs | 7 +- src/loops/run/launch-authority.test.mjs | 83 ++-- src/loops/run/m2-test-fixtures.mjs | 4 +- src/loops/run/production-runner.mjs | 123 ------ src/loops/run/reviewer-isolation.test.mjs | 20 - src/loops/run/run-artifacts.mjs | 23 +- src/loops/run/run-test-fixtures.mjs | 41 +- src/loops/run/runner.mjs | 14 +- src/loops/run/system-runner.mjs | 45 +++ src/loops/run/system-runner.test.mjs | 22 ++ website/src/content/docs/cli.mdx | 18 +- website/src/content/docs/loops.mdx | 70 ++-- 51 files changed, 610 insertions(+), 2283 deletions(-) create mode 100644 skills/burnlist/references/loop-provider-setup.md delete mode 100644 src/loops/adapters/codex-cli.mjs delete mode 100644 src/loops/adapters/codex-cli.test.mjs delete mode 100644 src/loops/adapters/normalized-invocation.mjs delete mode 100644 src/loops/adapters/normalized-invocation.test.mjs delete mode 100644 src/loops/agents/profile.mjs delete mode 100644 src/loops/agents/profile.test.mjs delete mode 100644 src/loops/config/profiles.mjs delete mode 100644 src/loops/run/binder.test.mjs delete mode 100644 src/loops/run/production-runner.mjs delete mode 100644 src/loops/run/reviewer-isolation.test.mjs create mode 100644 src/loops/run/system-runner.mjs create mode 100644 src/loops/run/system-runner.test.mjs diff --git a/README.md b/README.md index 9d73b9df..850dbf36 100644 --- a/README.md +++ b/README.md @@ -159,34 +159,34 @@ maker -> repository check -> fresh reviewer -> convergence gate -> CLI completio +------------ check failure / rejection ``` -Configure a write-authority maker and read-authority reviewer with `burnlist -agent profile add`, map them with `burnlist route set`, inspect and explicitly -trust the repository check with `burnlist loop capability inspect|trust`, then -assign an item with `burnlist loop assign`. Run `burnlist loop view ` +Inspect and explicitly trust the repository check with `burnlist loop +capability inspect|trust`, then assign an item with `burnlist loop assign`. +Burnlist stores no agent profile, provider route, subscription, or login. Run +`burnlist loop view ` and paste its complete ASCII output into the work handoff; it is the frozen -graph, pin, and completion-path record for that item. Create a Run with `loop create`, run -or resume it in the foreground with `loop run|resume`, and inspect it with -`loop status|inspect`. `pause` and `stop` are idle-Run recovery controls: they -require no active foreground owner. For a live foreground Run, Ctrl-C belongs -to that owner (first interrupt requests pause; second requests controlled -stop). Proof-gated `reconcile` is for a demonstrably lost owner. Only a +graph, pin, and completion-path record for that item. Create a Run with `loop +create`; for each agent node, claim it, invoke a native subagent or external +provider CLI from the host, and submit a bound report. Burnlist automatically +advances its trusted checks and gates to the next agent or terminal node. +Inspect with `loop status|inspect`; `pause` and `stop` are idle-Run controls, +and proof-gated `reconcile` handles a demonstrably lost claim. Only a converged Run can be applied by `loop complete`; the command is idempotent and performs the normal shrinking-list completion. -Hosts can also claim a prepared agent node, execute its exact bounded envelope, -and submit one identity-bound report. Burnlist remains transition and check -authority. Codex natively orchestrates Codex subagents and Claude natively -orchestrates Claude subagents; `builtin:codex-cli` is only the -Burnlist-managed process adapter, not the only Loop mechanism. The installed -skill provides the provider-neutral protocol and optional bounded recipes for -Codex CLI, AGY, Grok, and custom hosts. Host execution does not require, start, -or install Streaming Diff hooks. +The host owns every provider invocation. Codex and Claude can use native +subagents; any host can deliberately harness Codex CLI, AGY, Grok, or another +CLI through the installed skill's explicit recipes. Before the first Loop, the +skill inventories available native agents, CLIs, live access, and subscriptions +without reading credentials, shows the result, and asks which providers the +user wants to use or set up. Burnlist never installs, authenticates, configures, +or launches an agent provider. Host execution remains independent from +Streaming Diff hooks. The Checklist UI is read-only and shows the active node, attempt, results, -transition history, and paused, error, or terminal state. The runner enforces -the graph, fresh reviewer process, budgets, closed outcomes, and atomic CLI -writes. Reviewer filesystem write denial is **supervised**, not an OS sandbox. -Parallel execution, Docker isolation, metrics gates, custom adapters, +transition history, and paused, error, or terminal state. Burnlist enforces +the graph, claim identity, trusted checks, budgets, closed outcomes, and atomic +CLI writes. Provider permissions remain host-supervised, not an OS sandbox. +Parallel execution, Docker isolation, metrics gates, forecasting, worktrees, and background execution are deliberately unsupported in Stage 1. Items with no Loop assignment keep the ordinary direct `burnlist burn` workflow. diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 685679e0..753b542a 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -116,15 +116,12 @@ Usage: burnlist loop report --result [--repo ] burnlist loop abandon --reason [--repo ] burnlist loop list [--repo ] - burnlist loop run|resume [--repo ] burnlist loop status|inspect [--repo ] burnlist loop pause|stop [--repo ] (idle Run only) burnlist loop reconcile --recovery-proof [--repo ] burnlist loop complete [--repo ] burnlist loop capability ... burnlist loop setup status [--repo ] - burnlist agent ... - burnlist route set --profile [--repo ] burnlist register [path] burnlist unregister [path] burnlist roots [--prune] @@ -167,12 +164,6 @@ if (args[0] === "oven") { } else if (args[0] === "loop") { const { runLoopCliEntry } = await import("../src/cli/loop-cli.mjs"); await runLoopCliEntry(args.slice(1)); -} else if (args[0] === "agent") { - const { runAgentCliEntry } = await import("../src/cli/loop-config-cli.mjs"); - await runAgentCliEntry(args.slice(1)); -} else if (args[0] === "route") { - const { runRouteCliEntry } = await import("../src/cli/loop-config-cli.mjs"); - await runRouteCliEntry(args.slice(1)); } else if (["register", "unregister", "roots", "init"].includes(args[0])) { await import("../src/cli/registry-cli.mjs"); } else { diff --git a/loops/review/review.loop b/loops/review/review.loop index 05ee5580..de06888a 100644 --- a/loops/review/review.loop +++ b/loops/review/review.loop @@ -1,11 +1,11 @@ - - - - - - + + + + + + diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 5e16cc0e..65ca90a8 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -72,6 +72,7 @@ "skills/burnlist/references/differential-testing-data.md", "skills/burnlist/references/getting-started.md", "skills/burnlist/references/host-execution.md", + "skills/burnlist/references/loop-provider-setup.md", "skills/burnlist/references/installation.md", "skills/burnlist/references/loop-capability-example.json", "skills/burnlist/references/loop-providers/agy.md", @@ -115,9 +116,6 @@ "src/events/oven-event-store.mjs", "src/events/oven-event-stream-storage.mjs", "src/events/oven-events.mjs", - "src/loops/adapters/codex-cli.mjs", - "src/loops/adapters/normalized-invocation.mjs", - "src/loops/agents/profile.mjs", "src/loops/assignment/assignment.mjs", "src/loops/assignment/hazards.mjs", "src/loops/assignment/item-metadata.mjs", @@ -130,7 +128,6 @@ "src/loops/capabilities/snapshot.mjs", "src/loops/capabilities/trust.mjs", "src/loops/completion/completion.mjs", - "src/loops/config/profiles.mjs", "src/loops/config/setup.mjs", "src/loops/config/store.mjs", "src/loops/contracts/agent-result.mjs", @@ -156,7 +153,7 @@ "src/loops/run/controller.mjs", "src/loops/run/current-authority.mjs", "src/loops/run/host-execution.mjs", - "src/loops/run/production-runner.mjs", + "src/loops/run/system-runner.mjs", "src/loops/run/read-projection.mjs", "src/loops/run/run-artifacts.mjs", "src/loops/run/run-claim.mjs", diff --git a/scripts/smoke-global-install.mjs b/scripts/smoke-global-install.mjs index 92be1a72..c8a691d7 100755 --- a/scripts/smoke-global-install.mjs +++ b/scripts/smoke-global-install.mjs @@ -9,7 +9,6 @@ import { readlinkSync, realpathSync, rmSync, - chmodSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -85,22 +84,32 @@ function writeLoopFixture(repo) { mkdirSync(join(repo, "src"), { recursive: true }); mkdirSync(join(repo, "notes", "burnlists", "inprogress", "260722-001"), { recursive: true }); writeFileSync(join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"), "# Smoke Loop\n\n## Active Checklist\n- [ ] L1 | Packed Loop proof\n\n## Completed\n"); - const binary = join(repo, "fixtures", "fake-codex"); - mkdirSync(dirname(binary), { recursive: true }); - writeFileSync(binary, `#!${testNode} -const fs=require("node:fs"),args=process.argv.slice(2),prompt=args.at(-1),lines=Object.fromEntries(prompt.split("\\n").filter((line)=>line.includes("=")).map((line)=>line.split(/=(.*)/s).slice(0,2))); -const counter=process.env.BURNLIST_FAKE_COUNTER,index=counter?Number(fs.readFileSync(counter,"utf8")):0,outcome=(process.env.BURNLIST_FAKE_OUTCOMES||"complete,approve").split(",")[index]||"approve"; -if(counter)fs.writeFileSync(counter,String(index+1)); -const final={schema:"burnlist.agent-final@1",runId:lines.run,nodeId:lines.node,attempt:Number(lines.attempt),claimId:lines.claim,invocationId:lines.invocation,assignmentId:lines.assignment,recipeRevision:lines.recipe,policyRevision:lines.policy,inputCandidate:lines.candidate,outcome,summary:"fake "+outcome}; -process.stdout.write(JSON.stringify({type:"thread.started",thread_id:"smoke-"+process.pid,model:args[args.indexOf("-m")+1]})+"\\n"); -process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:JSON.stringify(final)}})+"\\n"); -process.stdout.write(JSON.stringify({type:"turn.completed",usage:{input_tokens:1,output_tokens:1,cached_input_tokens:0}})+"\\n"); -`); - chmodSync(binary, 0o700); const capability = { id: "repo-verify", argv: [testNode, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [capability] })}\n`); writeFileSync(join(repo, "grants.json"), `${JSON.stringify({ argv: capability.argv, cwd: capability.cwd, environment: capability.environment, network: capability.network, filesystem: capability.filesystem, output: capability.output, maxMilliseconds: capability.maxMilliseconds })}\n`); - return { binary, itemRef: "item:260722-001#L1" }; + return { itemRef: "item:260722-001#L1" }; +} + +function hostReport(execution, outcome) { + return { + schema: "burnlist-loop-host-report@1", + result: { + schema: "agent-result@1", + runId: execution.runId, + nodeId: execution.nodeId, + attempt: execution.attempt, + claimId: execution.claimId, + assignmentId: execution.assignmentId, + invocationId: execution.invocationId, + recipeRevision: execution.recipeRevision, + policyRevision: execution.policyRevision, + inputCandidate: execution.inputCandidate, + outcome, + findings: [], + resolvedFindingIds: [], + }, + telemetry: null, + }; } function assertLoopFlow(cli) { @@ -108,11 +117,7 @@ function assertLoopFlow(cli) { mkdirSync(repoPath, { recursive: true }); run("git", ["init", "--quiet", repoPath]); const repo = realpathSync(repoPath); - const { binary, itemRef } = writeLoopFixture(repo); - const profile = (slug, authority) => command(cli, repo, ["agent", "profile", "add", slug, "--adapter", "builtin:codex-cli", "--binary", binary, "--model", "gpt-5.6-terra", "--effort", "medium", "--authority", authority]); - profile("maker", "write"); profile("reviewer", "read"); - command(cli, repo, ["route", "set", "implementation.standard", "--profile", "maker"]); - command(cli, repo, ["route", "set", "review.strong", "--profile", "reviewer"]); + const { itemRef } = writeLoopFixture(repo); const capability = JSON.parse(command(cli, repo, ["loop", "capability", "inspect", "repo-verify"], { capture: true })); command(cli, repo, ["loop", "capability", "trust", "repo-verify", "--revision", capability.revision, "--grants", join(repo, "grants.json")]); const setup = command(cli, repo, ["loop", "setup", "status"], { capture: true }); @@ -122,8 +127,17 @@ function assertLoopFlow(cli) { if (!view.includes("LOOP: loop:builtin:review")) throw new Error("packed CLI did not render the assigned Loop"); const runId = JSON.parse(command(cli, repo, ["loop", "create", itemRef], { capture: true })).runId; for (const operation of ["status", "inspect"]) JSON.parse(command(cli, repo, ["loop", operation, runId], { capture: true })); - const counter = join(repo, "counter"); writeFileSync(counter, "0"); - const result = JSON.parse(command(cli, repo, ["loop", "run", runId], { capture: true, env: { ...env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,approve" } })); + const reportPath = join(tmpRoot, "host-report.json"); + let result; + for (let attempts = 0; attempts < 8; attempts += 1) { + result = JSON.parse(command(cli, repo, ["loop", "status", runId], { capture: true })); + if (result.state === "converged") break; + const execution = JSON.parse(command(cli, repo, ["loop", "claim", runId], { capture: true })).execution; + const outcome = ["review", "final-review"].includes(execution.nodeId) + ? "approve" : "complete"; + writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, outcome))}\n`); + command(cli, repo, ["loop", "report", execution.claimId, "--result", reportPath]); + } if (result.state !== "converged") throw new Error(`packed Loop did not converge: ${result.state}`); const first = JSON.parse(command(cli, repo, ["loop", "complete", runId], { capture: true })); const second = JSON.parse(command(cli, repo, ["loop", "complete", runId], { capture: true })); diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 7b5e326f..a1fa32b9 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -92,9 +92,6 @@ export const verificationTestFiles = [ "src/loops/dsl/project-strategies.test.mjs", "src/loops/view/render.test.mjs", "src/loops/capabilities/capabilities.test.mjs", - "src/loops/adapters/codex-cli.test.mjs", - "src/loops/adapters/normalized-invocation.test.mjs", - "src/loops/agents/profile.test.mjs", "src/loops/contracts/contracts.test.mjs", "src/loops/contracts/host-execution.test.mjs", "src/loops/run/run-store.test.mjs", @@ -109,9 +106,8 @@ export const verificationTestFiles = [ "src/loops/run/state-machine.test.mjs", "src/loops/run/budgets.test.mjs", "src/loops/run/runner.test.mjs", - "src/loops/run/binder.test.mjs", + "src/loops/run/system-runner.test.mjs", "src/loops/run/run-claim.test.mjs", - "src/loops/run/reviewer-isolation.test.mjs", "scripts/verify-source-scan.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", "src/server/oven-bindings.test.mjs", diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index b6660805..24491579 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -33,7 +33,8 @@ Read references only when their trigger applies: - `references/creating-ovens.md`: authoring a new .oven declarative source (grammar, elements, binding, themes, compile-to-IR walkthrough). - `references/oven-event-coordination.md`: mandatory for multi-Burnlist worker coordination, generic Oven progress events, replayable subscriptions, and event-triggered coordinator wakeups. - `references/host-execution.md`: generic host claim/execute/report protocol for a prepared Loop Run; read before a host executes a claimed agent node. -- `references/loop-providers/.md`: optional bounded provider recipe for Claude native, Codex native, Codex CLI, AGY, Grok, or a custom host. Read only after choosing that provider; native hosts do not need a recipe to use the generic protocol. +- `references/loop-provider-setup.md`: mandatory before the first Loop when available native agents, CLIs, logins, or subscriptions are unknown; inventory safely, show the user, and ask what to enable. +- `references/loop-providers/.md`: bounded invocation recipe for Claude native, Codex native, Codex CLI, AGY, Grok, or a custom host. Read the selected provider recipe before invoking it. Do not load cold references for a normal single-item implementation unless needed. If a task touches a cold-rule area, read the matching reference before editing Burnlist state in that area. @@ -161,8 +162,9 @@ assigns an item to it. It is one serial foreground path: maker, trusted repository check, fresh reviewer, convergence gate, then CLI-owned completion. Items without an assignment keep the direct Burnlist workflow. -Before creating a Run, configure distinct local profiles and routes, inspect -and explicitly trust the repository check capability, then assign the item: +Before creating a Run, inspect and explicitly trust the repository check +capability, then assign the item. Burnlist stores no provider profile, route, +subscription, binary, model, or login: The installed skill includes `references/loop-capability-example.json`. Copy its `catalog` object to the repository capability catalog and its `grants` @@ -178,30 +180,16 @@ the absolute check command and paths with your repository's reviewed command: {"argv":["/absolute/path/to/repository-check","--verify"],"cwd":".","environment":{"inherit":["PATH"],"set":{}},"network":"deny","filesystem":{"read":["src"],"write":[]},"output":{"maxBytes":65536},"maxMilliseconds":60000} ``` -Accepted profile models are `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, -and `gpt-5.3-codex-spark`; efforts are `minimal`, `low`, `medium`, `high`, -`xhigh`, and `max`. - -`builtin:codex-cli` is currently the only Burnlist-managed process adapter. -That does not limit native host orchestration: Codex normally runs Codex -subagents natively, Claude normally runs Claude subagents natively, and any host -may claim and report Loop nodes through native orchestration. Claude, Grok, AGY, -and custom hosts are not managed adapters, though they may optionally invoke -Codex through a `codex-cli` recipe; do not promise or configure them as managed -Loop backends. - -For a host-orchestrated agent node, read `references/host-execution.md` before -claiming it. The core protocol is provider-neutral: claim the prepared node, -execute its exact bounded envelope through an available native or external -mechanism, then return one bound report. Never invent a transition or execute a -managed check. Provider recipes are optional detail, not a prerequisite for -native host orchestration. +Every agent node is host-orchestrated. The host owns every provider invocation. +Read `references/host-execution.md` +before claiming it. If available subscriptions are unknown, read +`references/loop-provider-setup.md`, show the user the safe inventory, and ask +which ready providers to use or help set up. Then read the selected provider +recipe. The host may use native subagents or the Codex, AGY, Grok, or another +CLI, but it—not Burnlist—launches and supervises them. Never invent a +transition or execute a managed check. ```sh -burnlist agent profile add maker --adapter builtin:codex-cli --binary --model --effort --authority write -burnlist agent profile add reviewer --adapter builtin:codex-cli --binary --model --effort --authority read -burnlist route set implementation.standard --profile maker -burnlist route set review.strong --profile reviewer burnlist loop capability inspect repo-verify burnlist loop capability trust repo-verify --revision cp1-sha256: --grants burnlist loop assign item:# loop:builtin:review @@ -210,16 +198,12 @@ burnlist loop assign item:# loop:builtin:review Run `burnlist loop setup status` before `loop create`. Paste the complete `burnlist loop view item:#` ASCII output into the task handoff or review request: it records the frozen graph, retries, completion -path, and pins. Control a Run only with `loop run|pause|resume|stop`, -inspect with `loop status|inspect`, use proof-gated `loop reconcile` only for a -demonstrably lost foreground owner, and apply a converged Run through the -idempotent `loop complete` command. A valid reconcile proof terminalizes as -`needs-human`; it never pauses a Run. - -Standalone `pause` and `stop` only work while no foreground owner holds the -Run lease. For live foreground work, the owner handles Ctrl-C: first interrupt -requests pause after its child exits, second requests controlled stop. Do not -claim that a separate CLI invocation can take over a live Run. +path, and pins. For each agent node use `loop claim`, invoke the selected +provider through the host, and submit `loop report`. Reporting automatically +advances Burnlist-owned checks and gates to the next agent or terminal node. +Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`, +use proof-gated `loop reconcile` only for a demonstrably lost host claim, and +apply a converged Run through idempotent `loop complete`. The canonical Checklist Oven keeps a centered KPI row above the side-by-side Progress ledger and Completion trend. Its unified Items list contains current, @@ -237,7 +221,7 @@ Stage 1 labels fresh reviewer process, graph grammar, budgets, closed outcomes, and atomic canonical CLI writes `enforced`; ordinary drift checks are `detected-at-boundaries`; reviewer filesystem write denial is `supervised`. It is not Docker or an OS sandbox. Parallelism, nested agents, metric gates, -custom adapters, worktrees, background execution, Docker isolation, and +worktrees, background execution, Docker isolation, and forecasting are `unsupported`. The Checklist UI is read-only and displays active, paused, error, and terminal Run states; it never controls a Run. diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md index 009bde23..c97df94b 100644 --- a/skills/burnlist/references/host-execution.md +++ b/skills/burnlist/references/host-execution.md @@ -1,8 +1,8 @@ # Host-executed Loop nodes Use this reference when a host executes a prepared agent node. It is the whole -provider-neutral contract; choose a `loop-providers/` recipe only if its -invocation details help. +provider-neutral contract; choose the matching `loop-providers/` recipe before +invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. ## Claim, execute, report @@ -90,12 +90,12 @@ invocation details help. ## Ownership and observability -The host owns invocation and optional best-effort telemetry. Burnlist owns the -frozen graph, claim authority, validation, deterministic checks, transition -selection, canonical journal, and item completion. Native provider execution, -the managed `builtin:codex-cli` process adapter, and external tools all feed -this same boundary. `builtin:codex-cli` is optional for cross-engine use, not -the only Loop mechanism. +The host owns every provider invocation and optional best-effort telemetry. +Burnlist owns the frozen graph, claim authority, validation, deterministic +checks, transition selection, canonical journal, and item completion. After +`loop report`, Burnlist automatically advances trusted checks and graph-only +nodes until the next host agent claim or a terminal state. It never launches a +provider process. Installable skills and Streaming Diff hooks are independent. Neither installs, selects, or starts a host executor; hooks only provide optional observational diff --git a/skills/burnlist/references/loop-provider-setup.md b/skills/burnlist/references/loop-provider-setup.md new file mode 100644 index 00000000..c708211f --- /dev/null +++ b/skills/burnlist/references/loop-provider-setup.md @@ -0,0 +1,51 @@ +# Loop provider setup + +Use this before the first host-executed Loop when the user's available agent +subscriptions are unknown. Burnlist does not install, authenticate, configure, +or launch an agent provider. + +## Inventory first + +Check native subagents exposed by the current host, then perform only +non-mutating CLI discovery: + +```sh +command -v codex && codex --version +command -v agy && agy models +command -v grok && grok models +``` + +If an installed provider skill supplies a safer auth preflight, prefer it. +Never read or print token files. A binary or cached auth record does not prove a +paid subscription; a successful live model listing proves only current access. + +Present a compact inventory before assigning providers: + +```text +Provider Native/CLI Login Models or subscription Intended Loop roles +Codex CLI ready maker, reviewer +AGY CLI setup unknown optional reviewer +Grok CLI ready optional challenger +``` + +Ask the user whether they want to use the ready providers and whether they want +instructions for any missing login or installation. Do not start OAuth, install +a CLI, or alter provider configuration without that choice. + +## Login handoff + +- Codex: ask the user to run `codex login` if its own status or first invocation + reports that authentication is missing. +- AGY: ask the user to run `agy` interactively and complete browser OAuth. +- Grok: ask the user to run `grok login`. + +After the user completes setup, rerun the non-mutating live check. Provider +selection is working-session context, not Burnlist canonical state: never write +subscriptions, tokens, or provider profiles into `.burnlist/`. + +## Choose per node + +Use the Loop node's role, authority, and intelligence as selection guidance, +then choose among the providers the user made available. Record observed +provider/model/effort only in optional host telemetry. The `.loop` graph does +not pin a subscription or grant Burnlist authority to launch a provider. diff --git a/skills/burnlist/references/loop-providers/agy.md b/skills/burnlist/references/loop-providers/agy.md index c4ae8077..e1c8a117 100644 --- a/skills/burnlist/references/loop-providers/agy.md +++ b/skills/burnlist/references/loop-providers/agy.md @@ -1,17 +1,31 @@ # AGY recipe -Use only when AGY is available to the host. Read -[Host-executed Loop nodes](../host-execution.md) first, and defer to an -installed AGY skill for its current invocation syntax. +Read [Host-executed Loop nodes](../host-execution.md) first. The host owns the +AGY process; Burnlist never launches or configures it. -- **Invocation ownership:** the host invokes and supervises AGY; AGY is not a - Burnlist-managed adapter. -- **Context freedom:** choose AGY's prompt and session mechanics while carrying - the exact prepared envelope as the authority-bearing input. -- **Identity:** preserve the complete generic correlation tuple through AGY and - bind its final result to that tuple. -- **Telemetry:** use AGY data only when actually returned and label it - `host-reported`; otherwise leave it `null`. -- **Fallback:** use native host execution, Codex CLI where deliberately - configured, or abandon the claim. Never invent provider output or a graph - transition. +Preflight without reading credentials: + +```sh +command -v agy +agy models +``` + +If login is missing, ask the user to run `agy` interactively and complete its +browser OAuth. For a claimed node, put the decoded invocation and bounded +context in a prompt file and run AGY in the foreground: + +```sh +# Read-only review +agy --mode plan --sandbox --print-timeout 10m --print "$(cat )" \ + > 2>&1 + +# Explicitly authorized write node +agy --mode accept-edits --dangerously-skip-permissions --print-timeout 10m \ + --print "$(cat )" > 2>&1 +``` + +Add `--model ` and `--effort low|medium|high` only when intentionally +selected. The host supervises the process, preserves the complete correlation +tuple, verifies the workspace, and creates the bound report. AGY never chooses +the graph edge. Report only observed telemetry; otherwise use `null`. On +failure, use another host mechanism or abandon the claim. diff --git a/skills/burnlist/references/loop-providers/claude-native.md b/skills/burnlist/references/loop-providers/claude-native.md index 16889768..2a62ca7f 100644 --- a/skills/burnlist/references/loop-providers/claude-native.md +++ b/skills/burnlist/references/loop-providers/claude-native.md @@ -14,4 +14,4 @@ Read [Host-executed Loop nodes](../host-execution.md) first. them `host-reported`, otherwise use `null`. - **Fallback:** if native delegation is unavailable, execute directly as the Claude host or use another available mechanism, then follow the same report - and abandonment rules. Do not configure Claude as a managed adapter. + and abandonment rules. Burnlist never configures or launches Claude. diff --git a/skills/burnlist/references/loop-providers/codex-cli.md b/skills/burnlist/references/loop-providers/codex-cli.md index 1fd74061..3d8adddf 100644 --- a/skills/burnlist/references/loop-providers/codex-cli.md +++ b/skills/burnlist/references/loop-providers/codex-cli.md @@ -1,32 +1,38 @@ # Codex CLI recipe -Read [Host-executed Loop nodes](../host-execution.md) first. There are two -different Codex CLI paths; never describe a direct host launch as the managed -adapter. - -## Managed `builtin:codex-cli` - -- **Invocation ownership:** the Burnlist runner launches and cancels the - configured process through `loop run` or `loop resume`; the host does not - claim/report that managed invocation. -- **Context and identity:** the runner supplies the frozen input and preserves - its identity internally; neither the child nor a supervising host selects an - edge. -- **Telemetry:** runner measurements are `managed`; missing values are - unavailable, not inferred. -- **Fallback:** if managed setup is unavailable, use native host orchestration - or the direct host-claim path below; do not call that fallback an adapter. - -## Direct Codex CLI for a host claim - -- **Invocation ownership:** the host launches, supervises, and cancels its own - Codex CLI process after `burnlist loop claim`; this is not `builtin:codex-cli`. -- **Context freedom:** give Codex the exact decoded invocation input and only - bounded supplemental context. Do not ask it to choose a transition. -- **Identity:** retain the complete generic correlation tuple and submit the - one bound host report under the original claim id. -- **Telemetry:** observations from this host-owned process are - `host-reported`; unavailable fields are `null`. -- **Fallback:** use native Codex orchestration, another available host - mechanism, or abandon the claim. `builtin:codex-cli` remains the only - Burnlist-managed process adapter, not the only way a Loop node can run. +Read [Host-executed Loop nodes](../host-execution.md) first. The host owns the +Codex process; Burnlist never launches or configures it. + +## Preflight + +```sh +command -v codex +codex --version +``` + +If authentication is missing, ask the user to run `codex login`. Do not inspect +credential files. + +## Invoke + +Put the decoded prepared invocation plus bounded supplemental context in a +prompt file, then run one foreground process: + +```sh +codex exec --json --ephemeral \ + -m \ + -c model_reasoning_effort= \ + -s \ + -C \ + --skip-git-repo-check \ + -- "$(cat )" 2>&1 +``` + +Use `workspace-write` only for a write-authority task node and `read-only` for +review. Keep the process foreground and supervised; never start two writers in +one worktree. Parse its final answer, verify it against the workspace, and +construct the bound host report yourself. Codex output is not a graph +transition and must not replace any claim identity. + +Telemetry is `host-reported`; copy only values actually observed. If the +process cannot finish, abandon the claim rather than fabricating a result. diff --git a/skills/burnlist/references/loop-providers/custom.md b/skills/burnlist/references/loop-providers/custom.md index d55d9678..a0988966 100644 --- a/skills/burnlist/references/loop-providers/custom.md +++ b/skills/burnlist/references/loop-providers/custom.md @@ -12,4 +12,4 @@ Use for a host not covered by another recipe. Read - **Telemetry:** use only observed values with `host-reported`; leave absent fields `null`. - **Fallback:** execute directly if possible; otherwise abandon the live claim - with a permitted reason. A custom host is never silently a managed adapter. + with a permitted reason. Burnlist never configures or launches the provider. diff --git a/skills/burnlist/references/loop-providers/grok.md b/skills/burnlist/references/loop-providers/grok.md index 0c6f27c5..31eb3900 100644 --- a/skills/burnlist/references/loop-providers/grok.md +++ b/skills/burnlist/references/loop-providers/grok.md @@ -1,16 +1,33 @@ # Grok recipe -Use only when Grok is available to the host. Read -[Host-executed Loop nodes](../host-execution.md) first, and defer to an -installed Grok skill for its current invocation syntax. +Read [Host-executed Loop nodes](../host-execution.md) first. The host owns the +Grok process; Burnlist never launches or configures it. -- **Invocation ownership:** the host invokes and supervises Grok; Grok is not a - Burnlist-managed adapter. -- **Context freedom:** select Grok session mechanics while retaining the exact - prepared envelope as the only transition-relevant input. -- **Identity:** preserve the full generic correlation tuple and bind the final - report to it without reconstruction. -- **Telemetry:** emit Grok/model/usage only when known, as `host-reported`; - unknown values are `null`. -- **Fallback:** use a native host path, a deliberately configured Codex CLI - recipe, or abandon the claim. Do not promise Grok as a managed Loop backend. +Preflight without reading credentials: + +```sh +command -v grok +grok models +``` + +If login is missing, ask the user to run `grok login`. For a claimed node, put +the decoded invocation and bounded context in a prompt file and run Grok in the +foreground: + +```sh +# Read-only review +grok --cwd --prompt-file \ + --permission-mode plan --no-memory --no-subagents --disable-web-search \ + --output-format json > 2>&1 + +# Explicitly authorized write node +grok --cwd --prompt-file \ + --permission-mode auto --always-approve --no-memory --no-subagents \ + --disable-web-search --output-format json > 2>&1 +``` + +Add `--model `, `--reasoning-effort `, `--max-turns `, or +`--check` deliberately. The host supervises the process, preserves the full +correlation tuple, verifies the workspace, and constructs the bound report. +Grok never chooses a graph edge. Use only observed telemetry; abandon the claim +if no valid result can be produced. diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index 6bd06c9a..ab6bda9f 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -51,14 +51,13 @@ test("top-level and Oven help expose the validated use and set flow", () => { assert.match(top.stdout, /burnlist loop report --result \[--repo \]/u); assert.match(top.stdout, /burnlist loop abandon --reason \[--repo \]/u); assert.match(top.stdout, /burnlist loop list \[--repo \]/u); - assert.match(top.stdout, /burnlist loop run\|resume \[--repo \]/u); + assert.doesNotMatch(top.stdout, /burnlist loop run\|resume/u); assert.match(top.stdout, /burnlist loop status\|inspect \[--repo \]/u); assert.match(top.stdout, /burnlist loop pause\|stop \[--repo \] \(idle Run only\)/u); assert.match(top.stdout, /burnlist loop reconcile --recovery-proof \[--repo \]/u); assert.match(top.stdout, /burnlist loop complete \[--repo \]/u); assert.match(top.stdout, /burnlist loop capability \.\.\./u); - assert.match(top.stdout, /burnlist agent \.\.\./u); - assert.match(top.stdout, /burnlist route set --profile \[--repo \]/u); + assert.doesNotMatch(top.stdout, /burnlist agent |burnlist route set/u); const oven = run(context.directory, ["oven", "help"]); assert.equal(oven.status, 0, oven.stderr); @@ -73,19 +72,15 @@ test("top-level and Oven help expose the validated use and set flow", () => { test("Loop local configuration help exposes only explicit setup commands", () => { const context = fixture({ git: false }); try { - for (const [args, usage] of [ - [["agent", "--help"], /burnlist agent profile add /u], - [["agent", "--help"], /burnlist agent doctor /u], - [["route", "--help"], /burnlist route set /u], - [["loop", "--help"], /burnlist loop capability trust --revision cp1-sha256: --grants /u], - ]) { - const result = run(context.directory, args); - assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, usage); assert.equal(result.stderr, ""); - } - for (const args of [["agent", "controller", "add", "host"], ["agent", "preflight", "maker"]]) { + const loop = run(context.directory, ["loop", "--help"]); + assert.equal(loop.status, 0, loop.stderr); + assert.match(loop.stdout, + /burnlist loop capability trust --revision cp1-sha256: --grants /u); + assert.doesNotMatch(loop.stdout, /agent profile|route set|builtin:codex-cli/u); + for (const args of [["agent", "--help"], ["route", "--help"]]) { const result = run(context.directory, args); assert.equal(result.status, 2, args.join(" ")); - assert.match(result.stderr, /Usage: burnlist agent profile add/u); + assert.doesNotMatch(result.stderr, /builtin:codex-cli/u); } } finally { context.cleanup(); } }); @@ -95,10 +90,10 @@ test("nested Loop help snapshots every Stage 1 control", () => { try { const result = run(context.directory, ["loop", "--help"]); assert.equal(result.status, 0, result.stderr); - for (const command of ["create", "next|claim", "report --result ", "abandon --reason ", "list", "run|pause|resume|stop|complete", "status|inspect", "reconcile"]) { + for (const command of ["create", "next|claim", "report --result ", "abandon --reason ", "list", "pause|stop|complete", "status|inspect", "reconcile"]) { assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u")); } - assert.match(result.stdout, /pause\|resume\|stop\|complete /u); + assert.doesNotMatch(result.stdout, /loop run|resume/u); assert.equal(result.stderr, ""); } finally { context.cleanup(); } }); @@ -150,7 +145,7 @@ test("hooks status and uninstall name their own Git requirement", () => { test("Review Loop documentation command matrix runs against the production fixture", (t) => { const directory = mkdtempSync(join(tmpdir(), "burnlist-loop-docs-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo, binary } = createProductionRunAuthority(join(directory, "repo")); + const { repo } = createProductionRunAuthority(join(directory, "repo")); const loop = (...args) => run(repo, ["loop", ...args, "--repo", repo]); const command = (...args) => run(repo, [...args, "--repo", repo]); const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); @@ -162,15 +157,6 @@ test("Review Loop documentation command matrix runs against the production fixtu "", "## Completed", ].join("\n"))); - const profile = (slug, authority) => command("agent", "profile", "add", slug, - "--adapter", "builtin:codex-cli", "--binary", binary, "--model", "gpt-5.3-codex-spark", - "--effort", "medium", "--authority", authority); - for (const result of [profile("maker", "write"), profile("reviewer", "read"), - command("route", "set", "implementation.standard", "--profile", "maker"), - command("route", "set", "review.strong", "--profile", "reviewer"), - command("agent", "doctor", "maker"), command("agent", "doctor", "reviewer")]) { - assert.equal(result.status, 0, result.stderr); - } const inspected = loop("capability", "inspect", "repo-verify"); assert.equal(inspected.status, 0, inspected.stderr); const revision = JSON.parse(inspected.stdout).revision; @@ -199,19 +185,11 @@ test("Review Loop documentation command matrix runs against the production fixtu const result = loop(...args); assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); } - const counter = join(directory, "counter"); - writeFileSync(counter, "0"); - const executed = spawnSync(process.execPath, [cli, "loop", "run", runId, "--repo", repo], { - cwd: repo, - encoding: "utf8", - env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" }, - }); - assert.equal(executed.status, 0, executed.stderr); - assert.equal(JSON.parse(executed.stdout).state, "converged"); - for (const args of [["complete", runId], ["complete", runId]]) { - const result = loop(...args); - assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr}`); - } + const claim = loop("claim", runId); + assert.equal(claim.status, 0, claim.stderr); + assert.equal(JSON.parse(claim.stdout).execution.nodeId, "start"); + assert.equal(loop("abandon", JSON.parse(claim.stdout).execution.claimId, + "--reason", "host-cancelled").status, 0); const pauseRef = "item:260722-001#L31"; assert.equal(loop("assign", pauseRef, "loop:builtin:review").status, 0); @@ -219,13 +197,9 @@ test("Review Loop documentation command matrix runs against the production fixtu const paused = loop("pause", pausedRun); assert.equal(paused.status, 0, paused.stderr); assert.equal(JSON.parse(paused.stdout).state, "paused"); - writeFileSync(counter, "0"); - const resumed = spawnSync(process.execPath, [cli, "loop", "resume", pausedRun, "--repo", repo], { - cwd: repo, encoding: "utf8", - env: { ...process.env, BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" }, - }); + const resumed = loop("claim", pausedRun); assert.equal(resumed.status, 0, resumed.stderr); - assert.equal(JSON.parse(resumed.stdout).state, "converged"); + assert.equal(JSON.parse(resumed.stdout).execution.nodeId, "start"); const stoppedRef = "item:260722-001#L32"; assert.equal(loop("assign", stoppedRef, "loop:builtin:review").status, 0); @@ -251,14 +225,13 @@ test("Review Loop documentation preserves Stage 1 boundaries", () => { join(root, "website", "src", "content", "docs", "loops.mdx"), join(root, "skills", "burnlist", "SKILL.md"), ].map((path) => readFileSync(path, "utf8")).join("\n"); - assert.match(files, /filesystem write denial.*supervised/us); - assert.match(files, /fresh reviewer process.*enforced/us); + assert.match(files, /host owns every provider invocation/us); assert.match(files, /[Pp]arallelism.*unsupported/us); assert.match(files, /Docker isolation.*unsupported/us); - assert.match(files, /custom adapters.*unsupported/us); + assert.match(files, /never .*launches an? .*provider/us); assert.match(files, /forecasting.*unsupported/us); assert.match(files, /skill.*hooks.*independent/us); assert.match(files, /loop-capability-example\.json/us); - assert.match(files, /gpt-5\.6-terra/us); - assert.match(files, /idle.*foreground owner/us); + assert.match(files, /loop-provider-setup\.md/us); + assert.match(files, /Codex.*AGY.*Grok/us); }); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs index 68415d9c..f0843d1a 100644 --- a/src/cli/loop-cli.mjs +++ b/src/cli/loop-cli.mjs @@ -7,7 +7,7 @@ import { loopConfigUsage, runLoopConfigCli } from "./loop-config-cli.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; import { runStore } from "../loops/run/run-store.mjs"; import { createLoopController } from "../loops/run/controller.mjs"; -import { createProductionRun, createStoredProductionRunRunner } from "../loops/run/binder.mjs"; +import { createProductionRun, createStoredSystemRunRunner } from "../loops/run/binder.mjs"; import { completeLoopRun } from "../loops/completion/completion.mjs"; function usageText() { return loopConfigUsage(); } @@ -66,7 +66,7 @@ export async function renderLoopView({ selector, repoRoot, runReader }) { return { authority, output: renderResolvedLoopView(authority) }; } -export async function runLoopCli(tokens, { runReader, runnerFor, stdout = process.stdout, processObject = process } = {}) { +export async function runLoopCli(tokens, { runReader, runnerFor, stdout = process.stdout } = {}) { if (tokens[0] === "--help" || tokens[0] === "-h") { stdout.write(`${usageText()}\n`); return null; } if (["capability", "setup"].includes(tokens[0])) { const value = await runLoopConfigCli(tokens); stdout.write(value.output); return value; @@ -83,11 +83,11 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces const result = completeLoopRun({ repoRoot: opts.repo, runId: opts.positionals[0] }); stdout.write(`${JSON.stringify({ schema: "burnlist-loop-completion@1", ...result })}\n`); return result; } - if (["list", "status", "inspect", "next", "claim", "report", "abandon", "run", "pause", "resume", "stop", "reconcile"].includes(verb)) { + if (["list", "status", "inspect", "next", "claim", "report", "abandon", "pause", "stop", "reconcile"].includes(verb)) { const allowed = verb === "list" ? 0 : 1; if (opts.positionals.length !== allowed) throw usageError(); const store = runStore(opts.repo); - const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredProductionRunRunner({ repoRoot: opts.repo, store, runId })); + const suppliedRunnerFor = runnerFor ?? ((runId) => createStoredSystemRunRunner({ repoRoot: opts.repo, store, runId })); const runners = new Map(), runtimeRunnerFor = (runId) => { if (!runners.has(runId)) runners.set(runId, suppliedRunnerFor(runId)); return runners.get(runId); @@ -98,7 +98,7 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces : verb === "inspect" ? controller.inspect(opts.positionals[0]) : verb === "next" ? controller.inspect(opts.positionals[0]) : verb === "claim" ? publicClaim(controller.claim(opts.positionals[0])) - : verb === "report" ? controller.report(opts.positionals[0], resultBytes(opts.resultFile)) + : verb === "report" ? await controller.report(opts.positionals[0], resultBytes(opts.resultFile)) : verb === "abandon" ? (() => { const runId = store.resolveClaimRef(opts.positionals[0]); if (store.read(runId).execution.terminal) { const error = new Error("ClaimRef is stale"); error.exitCode = 1; throw error; } @@ -109,12 +109,7 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces : verb === "pause" ? controller.pause(opts.positionals[0]) : verb === "stop" ? controller.stop(opts.positionals[0]) : verb === "reconcile" ? controller.reconcile(opts.positionals[0], opts.recoveryProof ? { generation: store.read(opts.positionals[0]).execution.generation, recoveryProof: opts.recoveryProof } : null) - : verb === "resume" || verb === "run" ? await (() => { - const runner = runtimeRunnerFor(opts.positionals[0]); let signalled = false; - const onInterrupt = () => { if (!signalled) { signalled = true; runner.requestPause?.(); } else runner.requestStop?.(); }; - processObject.on?.("SIGINT", onInterrupt); - return controller.run(opts.positionals[0]).finally(() => processObject.removeListener?.("SIGINT", onInterrupt)); - })() : null; + : null; stdout.write(controller.render(result)); return result; } if (verb === "assign" && opts.positionals.length === 2) { diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs index 547aa249..6ce5e8e1 100644 --- a/src/cli/loop-config-cli.mjs +++ b/src/cli/loop-config-cli.mjs @@ -2,15 +2,11 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from " import { join, parse as parsePath, relative, resolve, sep } from "node:path"; import { readCapabilityCatalog, resolveCapability } from "../loops/capabilities/contract.mjs"; import { trustCapability } from "../loops/capabilities/trust.mjs"; -import { doctorProfile, saveProfile, saveRoute } from "../loops/config/profiles.mjs"; import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs"; import { rawSha256 } from "../loops/dsl/hash.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; -const profileUsage = "Usage: burnlist agent profile add --adapter builtin:codex-cli --binary --model --effort --authority read|write [--repo ]"; -const routeUsage = "Usage: burnlist route set --profile [--repo ]"; -const agentUsage = `${profileUsage}\n burnlist agent doctor [--repo ]`; -const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report --result [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop run|pause|resume|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report --result [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop pause|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } function parse(tokens, allowed = []) { @@ -63,36 +59,6 @@ function boundedJson(path) { } export function loopConfigUsage() { return loopUsage; } -export function agentConfigUsage() { return agentUsage; } - -export async function runAgentCli(tokens, { stdout = process.stdout } = {}) { - if (tokens[0] === "--help" || tokens[0] === "-h") return { output: `${agentUsage}\n` }; - const [verb, ...tail] = tokens; - if (verb === "profile" && tail[0] === "add") { - const rest = tail.slice(1); - const parsed = parse(rest, ["--adapter", "--binary", "--model", "--effort", "--authority", "--repo"]); requireOnly(parsed.positionals, 1, profileUsage); - for (const key of ["--adapter", "--binary", "--model", "--effort", "--authority"]) if (!parsed.values[key]) fail(profileUsage); - const result = saveProfile({ repoRoot: parsed.repo, slug: parsed.positionals[0], adapter: parsed.values["--adapter"], binary: parsed.values["--binary"], model: parsed.values["--model"], effort: parsed.values["--effort"], authority: parsed.values["--authority"] }); - return { output: canonical(result), result }; - } - if (verb === "doctor") { - const parsed = parse(tail, ["--repo"]); requireOnly(parsed.positionals, 1, agentUsage); - const result = await doctorProfile({ repoRoot: parsed.repo, slug: parsed.positionals[0] }); - if (result.available) return { output: `Agent ${result.profile.id}: available\nConfiguration authority: profile record only; launch verification is deferred.\n`, result }; - return { output: `Agent ${result.profile.id}: unavailable, not ready\nREMEDIATION: ${profileUsage}\n`, result, exitCode: 1 }; - } - fail(agentUsage); -} - -export function runRouteCli(tokens) { - if (tokens[0] === "--help" || tokens[0] === "-h") return { output: `${routeUsage}\n` }; - const [verb, ...rest] = tokens; - if (verb !== "set") fail(routeUsage); - const parsed = parse(rest, ["--profile", "--repo"]); requireOnly(parsed.positionals, 1, routeUsage); - if (!parsed.values["--profile"]) fail(routeUsage); - const result = saveRoute({ repoRoot: parsed.repo, route: parsed.positionals[0], profile: parsed.values["--profile"] }); - return { output: canonical(result), result }; -} export async function runLoopConfigCli(tokens) { const [kind, action, ...rest] = tokens; @@ -118,11 +84,3 @@ export async function runLoopConfigCli(tokens) { } export function writeCliResult(value, stdout = process.stdout) { stdout.write(value.output); return value.result ?? null; } -export async function runAgentCliEntry(tokens = process.argv.slice(3)) { - try { const value = await runAgentCli(tokens); writeCliResult(value); process.exitCode = value.exitCode ?? 0; return value.result ?? null; } - catch (error) { process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); process.exitCode = error?.exitCode ?? 1; return null; } -} -export async function runRouteCliEntry(tokens = process.argv.slice(3)) { - try { const value = runRouteCli(tokens); writeCliResult(value); process.exitCode = value.exitCode ?? 0; return value.result ?? null; } - catch (error) { process.stderr.write(`burnlist: ${error?.message ?? String(error)}\n`); process.exitCode = error?.exitCode ?? 1; return null; } -} diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs index 2bb75c9e..ae109d3d 100644 --- a/src/cli/loop-runtime-cli.test.mjs +++ b/src/cli/loop-runtime-cli.test.mjs @@ -1,328 +1,107 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawn, spawnSync } from "node:child_process"; -import { EventEmitter } from "node:events"; +import { spawnSync } from "node:child_process"; import test from "node:test"; import { createProductionRunAuthority, fixtureItemRef } from "../loops/run/run-test-fixtures.mjs"; -import { runLoopCli } from "./loop-cli.mjs"; -import { runStore } from "../loops/run/run-store.mjs"; -import { createLoopController } from "../loops/run/controller.mjs"; -import { prepareHostClaim } from "../loops/run/host-execution.mjs"; -import { createProductionRun } from "../loops/run/binder.mjs"; -import { fixtureRunId } from "../loops/run/run-test-fixtures.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const cli = join(root, "bin", "burnlist.mjs"); -function command(repo, args, env = {}) { - const result = spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8", env: { ...process.env, ...env } }); - assert.equal(result.stderr, "", `${args.join(" ")}: ${result.stderr}`); assert.equal(result.status, 0, `${args.join(" ")}: ${result.stdout}`); return result.stdout; + +function invoke(repo, args) { + return spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], + { cwd: repo, encoding: "utf8" }); +} +function command(repo, args) { + const result = invoke(repo, args); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + return result.stdout; +} +function created(repo) { + return JSON.parse(command(repo, ["create", fixtureItemRef])).runId; } -function created(repo) { return JSON.parse(command(repo, ["create", fixtureItemRef])).runId; } function hostReport(execution, outcome) { return { schema: "burnlist-loop-host-report@1", result: { - schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, attempt: execution.attempt, - claimId: execution.claimId, assignmentId: execution.assignmentId, invocationId: execution.invocationId, + schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, + attempt: execution.attempt, claimId: execution.claimId, + assignmentId: execution.assignmentId, invocationId: execution.invocationId, recipeRevision: execution.recipeRevision, policyRevision: execution.policyRevision, - inputCandidate: execution.inputCandidate, outcome, findings: [], resolvedFindingIds: [], + inputCandidate: execution.inputCandidate, outcome, findings: [], + resolvedFindingIds: [], }, telemetry: null }; } -test("real CLI control reads are stable, list is absent-state read-only, and stored authority drives run/resume", (t) => { - const directory = mkdtempSync(join(tmpdir(), "m6-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); +test("host-only CLI exposes stable reads and no managed run command", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-only-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")); - const runs = join(repo, ".local", "burnlist", "loop", "m2", "runs"); - assert.equal(command(repo, ["list"]), "[]\n"); assert.equal(existsSync(runs), false); - const first = created(repo), status = command(repo, ["status", first]), inspect = command(repo, ["inspect", first]); - for (const publicView of [JSON.parse(status), JSON.parse(inspect)]) { - assert.equal(publicView.loopId, "review"); - assert.match(publicView.loopRevision, /^er1-sha256:[a-f0-9]{64}$/u); - assert.equal(Number.isSafeInteger(publicView.createdAt), true); - assert.equal(Number.isSafeInteger(publicView.updatedAt), true); + assert.equal(command(repo, ["list"]), "[]\n"); + const runId = created(repo); + const status = JSON.parse(command(repo, ["status", runId])); + assert.equal(status.currentNode, "start"); + assert.equal(command(repo, ["status", runId]), command(repo, ["status", runId])); + for (const verb of ["run", "resume"]) { + const result = invoke(repo, [verb, runId]); + assert.equal(result.status, 2); + assert.doesNotMatch(result.stderr, /codex|adapter|profile/u); } - assert.equal(command(repo, ["status", first]), status); assert.equal(command(repo, ["inspect", first]), inspect); - const authority = join(runs, Buffer.from(first).toString("hex"), "dispatch-authority.json"), bytes = readFileSync(authority); - const counter = join(directory, "counter"); writeFileSync(counter, "0"); - const completed = JSON.parse(command(repo, ["run", first], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" })); - assert.equal(completed.state, "converged"); assert.deepEqual(readFileSync(authority), bytes); - const blocked = spawnSync(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(blocked.status, 1); assert.match(blocked.stderr, /current Run is still executable/u); -}); - -test("real CLI fences active control and proof-gates reconcile", (t) => { - const directory = mkdtempSync(join(tmpdir(), "m6-cli-fence-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import{runStore}from${JSON.stringify(new URL("../loops/run/run-store.mjs", import.meta.url).href)};const s=runStore(process.argv[1]),a=s.acquireLease(process.argv[2]);s.append(process.argv[2],a.lease,"node-started",{nodeId:"start",attempt:1});s.append(process.argv[2],a.lease,"invocation-started",{nodeId:"start",attempt:1,invocationId:"${"a".repeat(32)}"});process.stdout.write(a.recoveryProof);`, repo, runId], { cwd: repo, encoding: "utf8" }); - assert.equal(held.status, 0, held.stderr); - const result = spawnSync(process.execPath, [cli, "loop", "pause", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(result.status, 1); assert.match(result.stderr, /active foreground owner/u); - const reconcile = spawnSync(process.execPath, [cli, "loop", "reconcile", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(reconcile.status, 1); assert.match(reconcile.stderr, /not demonstrably lost/u); - assert.equal(JSON.parse(command(repo, ["reconcile", runId, "--recovery-proof", held.stdout])).state, "needs-human"); }); -test("loop complete is the public, idempotent completion command", (t) => { - const directory = mkdtempSync(join(tmpdir(), "m8-cli-complete-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), counter = join(directory, "counter"); - writeFileSync(counter, "0"); assert.equal(JSON.parse(command(repo, ["run", runId], { BURNLIST_FAKE_COUNTER: counter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,approve,complete,approve" })).state, "converged"); +test("claim and report advance checks and gates without launching a provider", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-report-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runId = created(repo), reportPath = join(directory, "report.json"); + const visited = []; + for (let attempts = 0; attempts < 12; attempts += 1) { + const state = JSON.parse(command(repo, ["status", runId])); + if (state.state === "converged") break; + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + visited.push(execution.nodeId); + const outcome = ["review", "final-review"].includes(execution.nodeId) + ? "approve" : "complete"; + writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, outcome))}\n`); + command(repo, ["report", execution.claimId, "--result", reportPath]); + } + assert.deepEqual(visited, + ["start", "decompose", "implement", "review", "integrate", "final-review"]); + assert.equal(JSON.parse(command(repo, ["status", runId])).state, "converged"); assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, false); assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true); }); -test("a stopped current Run permits a safe replacement, while an executable Run does not", (t) => { - const directory = mkdtempSync(join(tmpdir(), "m8-current-replace-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), first = created(repo); - assert.equal(JSON.parse(command(repo, ["stop", first])).state, "stopped"); - const second = created(repo); assert.notEqual(second, first); - const old = spawnSync(process.execPath, [cli, "loop", "run", first, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(old.status, 1); assert.match(old.stderr, /superseded and cannot launch/u); -}); - -test("ordinary CLI create recovers an unpublished current reservation without requiring its RunRef", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "m12-cli-create-retry-")); t.after(() => rmSync(directory, { recursive: true, force: true })); +test("a claimed provider cannot be stolen and reviewer candidate drift rejects its report", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-claim-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); const { repo } = createProductionRunAuthority(join(directory, "repo")); - const cut = runStore(repo, { hooks: { beforeRunPublish() { throw new Error("publication cut"); } } }); - await assert.rejects(createProductionRun({ repoRoot: repo, store: cut, itemRef: fixtureItemRef, runId: fixtureRunId }), /publication cut/u); - assert.equal(cut.readCurrentRun(fixtureItemRef).runId, fixtureRunId); - assert.equal(JSON.parse(command(repo, ["create", fixtureItemRef])).runId, fixtureRunId); -}); - -test("concurrent replacement creates exactly one executable current Run", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "m8-current-race-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), first = created(repo); - command(repo, ["stop", first]); - const launch = () => new Promise((resolvePromise) => { - const child = spawn(process.execPath, [cli, "loop", "create", fixtureItemRef, "--repo", repo], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); let stdout = "", stderr = ""; - child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("close", (status) => resolvePromise({ status, stdout, stderr })); - }); - const results = await Promise.all([launch(), launch()]); - assert.equal(results.filter((result) => result.status === 0).length, 1); assert.equal(results.filter((result) => result.status === 1).length, 1); - const current = runStore(repo).readCurrentRun(fixtureItemRef); assert.equal(current.runId, JSON.parse(results.find((result) => result.status === 0).stdout).runId); -}); - -test("a second CLI SIGINT reaches controlled stop before the foreground runner settles", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "m6-cli-signal-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const processObject = new EventEmitter(), calls = []; - let settle; - const pending = new Promise((resolvePromise) => { settle = resolvePromise; }); - const store = runStore(repo); - const runner = { - requestPause() { calls.push("pause"); }, - requestStop() { calls.push("stop"); }, - async run() { await pending; return store.read(runId); }, - }; - const output = { value: "", write(chunk) { this.value += chunk; } }; - const commandPromise = runLoopCli(["run", runId, "--repo", repo], { - processObject, - runnerFor: () => runner, - stdout: output, - }); - processObject.emit("SIGINT"); - processObject.emit("SIGINT"); - assert.deepEqual(calls, ["pause", "stop"]); - assert.equal(processObject.listenerCount("SIGINT"), 1); - settle(); - await commandPromise; - assert.equal(processObject.listenerCount("SIGINT"), 0); - assert.equal(JSON.parse(output.value).runId, runId); -}); - -test("host CLI claims once, rejects claim theft, accepts one bound report, rejects conflict, and observes the next node after restart", (t) => { - const directory = mkdtempSync(join(tmpdir(), "s1-host-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - assert.equal(JSON.parse(command(repo, ["next", runId])).currentNode, "start"); - const first = JSON.parse(command(repo, ["claim", runId])); - const replayed = spawnSync(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(replayed.status, 1); - assert.equal(replayed.stdout, ""); - assert.match(replayed.stderr, /already claimed/u); - assert.equal(runStore(repo).read(runId).journal.filter((record) => record.value.type === "external-claim-bound").length, 1); - const binding = first.execution; - const report = { - schema: "burnlist-loop-host-report@1", - result: { - schema: "agent-result@1", runId: binding.runId, nodeId: binding.nodeId, attempt: binding.attempt, - claimId: binding.claimId, assignmentId: binding.assignmentId, invocationId: binding.invocationId, - recipeRevision: binding.recipeRevision, policyRevision: binding.policyRevision, - inputCandidate: binding.inputCandidate, outcome: "complete", findings: [], resolvedFindingIds: [], - }, - telemetry: null, - }; - const resultPath = join(directory, "report.json"); writeFileSync(resultPath, `${JSON.stringify(report)}\n`); - const runRefReport = spawnSync(process.execPath, [cli, "loop", "report", runId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(runRefReport.status, 1); assert.match(runRefReport.stderr, /invalid ClaimRef/u); - const missingClaim = `cl1-sha256:${"f".repeat(64)}`; - const missing = spawnSync(process.execPath, [cli, "loop", "report", missingClaim, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(missing.status, 1); assert.match(missing.stderr, /ClaimRef is missing/u); - assert.equal(JSON.parse(command(repo, ["report", binding.claimId, "--result", resultPath])).currentNode, "decompose"); - assert.equal(JSON.parse(command(repo, ["report", binding.claimId, "--result", resultPath])).currentNode, "decompose"); - writeFileSync(resultPath, `${JSON.stringify({ ...report, telemetry: { - schema: "burnlist-loop-host-telemetry@1", provenance: "host-reported", executor: "fixture", - displayName: null, provider: null, model: null, effort: null, startedAt: null, completedAt: null, - inputTokens: null, outputTokens: null, - } })}\n`); - const conflict = spawnSync(process.execPath, [cli, "loop", "report", binding.claimId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(conflict.status, 1); assert.match(conflict.stderr, /conflicts/u); - assert.equal(JSON.parse(command(repo, ["next", runId])).currentNode, "decompose"); -}); - -test("host CLI rejects misplaced result options and unsafe files, then abandons only its active claim", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h4-host-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claimed = JSON.parse(command(repo, ["claim", runId])).execution, reportPath = join(directory, "report.json"); - writeFileSync(reportPath, `${JSON.stringify(hostReport(claimed, "complete"))}\n`); - const invoke = (...args) => spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); - const misplaced = invoke("claim", runId, "--result", reportPath); - assert.equal(misplaced.status, 2); assert.match(misplaced.stderr, /Usage: burnlist loop/u); - const missing = invoke("report", claimed.claimId); - assert.equal(missing.status, 2); assert.match(missing.stderr, /Usage: burnlist loop/u); - const symlink = join(directory, "report-link.json"); symlinkSync(reportPath, symlink); - const unsafe = invoke("report", claimed.claimId, "--result", symlink); - assert.equal(unsafe.status, 1); assert.match(unsafe.stderr, /unsafe or exceeds bounds/u); - assert.equal(runStore(repo).read(runId).projection.currentNode, "start"); - const invalidReason = invoke("abandon", claimed.claimId, "--reason", "retry"); - assert.equal(invalidReason.status, 1); assert.match(invalidReason.stderr, /invalid host claim abandonment reason/u); - const abandoned = invoke("abandon", claimed.claimId, "--reason", "host-cancelled"); - assert.equal(abandoned.status, 0, abandoned.stderr); assert.equal(JSON.parse(abandoned.stdout).state, "needs-human"); - const stale = invoke("abandon", claimed.claimId, "--reason", "host-cancelled"); - assert.equal(stale.status, 1); assert.match(stale.stderr, /stale|missing/u); -}); - -test("Loop option syntax and ownership fail as usage errors before every verb dispatch", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h4-option-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")); - const invoke = (...args) => spawnSync(process.execPath, [cli, "loop", ...args, "--repo", repo], { cwd: repo, encoding: "utf8" }); - for (const args of [ - ["create", fixtureItemRef, "--result", "report.json"], ["complete", "run:bad", "--reason", "host-lost"], - ["assign", fixtureItemRef, "loop:builtin:review", "--recovery-proof", "a".repeat(64)], ["unassign", fixtureItemRef, "--result", "report.json"], - ["view", fixtureItemRef, "--reason", "host-lost"], ["list", "--recovery-proof", "a".repeat(64)], - ]) { - const result = invoke(...args); assert.equal(result.status, 2, args.join(" ")); assert.match(result.stderr, /Usage: burnlist loop/u); - } - for (const args of [ - ["list", "--unknown"], ["list", "--repo", repo], ["list", "--repo", repo], ["list", "--result"], - ["list", "--recovery-proof", "not-hex"], ["list", "--reason", "host-lost", "--reason", "host-cancelled"], - ]) { - const result = invoke(...args); assert.equal(result.status, 2, args.join(" ")); assert.doesNotMatch(result.stderr, /Loop control:/u); - } -}); - -test("a review claim refuses workspace drift before its report can advance", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h3-review-drift-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo), resultPath = join(directory, "report.json"); + const runId = created(repo), reportPath = join(directory, "report.json"); for (const expected of ["start", "decompose", "implement"]) { const execution = JSON.parse(command(repo, ["claim", runId])).execution; assert.equal(execution.nodeId, expected); - writeFileSync(resultPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`); - command(repo, ["report", execution.claimId, "--result", resultPath]); + writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`); + command(repo, ["report", execution.claimId, "--result", reportPath]); } - const store = runStore(repo), validation = store.acquireLease(runId).lease, candidateId = store.read(runId).execution.candidate.id; - store.append(runId, validation, "node-started", { nodeId: "validate", attempt: 1 }); - store.append(runId, validation, "invocation-started", { nodeId: "validate", attempt: 1, invocationId: "a".repeat(32) }); - store.append(runId, validation, "invocation-result", { invocationId: "a".repeat(32), kind: "pass", summary: "trusted", outputBytes: 0, candidateId }); - store.append(runId, validation, "edge-taken", { from: "validate", on: "pass", to: "review" }); store.releaseLease(runId, validation); - const controller = createLoopController({ store, repoRoot: repo }); - const capturedBeforeDrift = prepareHostClaim({ repoRoot: repo, replay: store.read(runId), authority: store.readAuthority(runId) }); - const preClaimDrift = join(repo, "src", "drift-before-review-claim.txt"); writeFileSync(preClaimDrift, "changed\n"); - assert.throws(() => controller.claim(runId, capturedBeforeDrift), /candidate drift before review claim/u); - rmSync(preClaimDrift); - const review = JSON.parse(command(repo, ["claim", runId])).execution; - assert.equal(review.nodeId, "review"); - writeFileSync(join(repo, "src", "drift-after-review-claim.txt"), "changed\n"); - writeFileSync(resultPath, `${JSON.stringify(hostReport(review, "approve"))}\n`); - const rejected = spawnSync(process.execPath, [cli, "loop", "report", review.claimId, "--result", resultPath, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(rejected.status, 1); assert.match(rejected.stderr, /candidate drifted/u); - assert.equal(store.read(runId).projection.currentNode, "review"); + const claimed = JSON.parse(command(repo, ["claim", runId])).execution; + assert.equal(claimed.nodeId, "review"); + const theft = invoke(repo, ["claim", runId]); + assert.equal(theft.status, 1); + assert.match(theft.stderr, /already claimed/u); + writeFileSync(join(repo, "src", "drift.txt"), "drift\n"); + writeFileSync(reportPath, `${JSON.stringify(hostReport(claimed, "approve"))}\n`); + const rejected = invoke(repo, ["report", claimed.claimId, "--result", reportPath]); + assert.equal(rejected.status, 1); + assert.match(rejected.stderr, /candidate drifted/u); }); -test("host report retries complete every committed transaction tail after deliberate restart cuts", (t) => { - for (const cut of ["afterExternalReportAccepted", "afterExternalEdgeTaken"]) { - const directory = mkdtempSync(join(tmpdir(), `h2-host-${cut}-`)); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claimed = JSON.parse(command(repo, ["claim", runId])).execution; - const report = Buffer.from(`${JSON.stringify({ - schema: "burnlist-loop-host-report@1", - result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, - claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, - recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, - outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null, - })}\n`); - const cutStore = runStore(repo, { hooks: { [cut]() { throw new Error(`deliberate ${cut}`); } } }); - const interrupted = createLoopController({ store: cutStore, repoRoot: repo }); - assert.throws(() => interrupted.report(claimed.claimId, report), new RegExp(`deliberate ${cut}`, "u")); - const restartedStore = runStore(repo), restarted = createLoopController({ store: restartedStore, repoRoot: repo }); - assert.equal(restarted.report(claimed.claimId, report).currentNode, "decompose"); - const replay = restartedStore.read(runId); - assert.equal(replay.execution.lease, null); - assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 1); - assert.equal(replay.journal.filter((record) => record.value.type === "edge-taken").length, 1); - assert.equal(replay.journal.filter((record) => record.value.type === "lease-released").length, 1); - assert.equal(restarted.report(claimed.claimId, report).currentNode, "decompose"); - } -}); - -test("concurrent external claims expose the envelope to exactly one winner", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "h2-host-claim-race-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claim = () => new Promise((done) => { - const child = spawn(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = "", stderr = ""; - child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.on("close", (status) => done({ status, stdout, stderr })); - }); - const results = await Promise.all([claim(), claim()]), successful = results.filter((value) => value.status === 0); - assert.equal(successful.length, 1); - assert.match(results.find((value) => value.status !== 0).stderr, /locked|lease|active foreground owner|already claimed/u); - const retry = spawnSync(process.execPath, [cli, "loop", "claim", runId, "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(retry.status, 1); - assert.match(retry.stderr, /already claimed/u); - assert.equal(runStore(repo).read(runId).journal.filter((record) => record.value.type === "external-claim-bound").length, 1); -}); - -test("near journal capacity terminalizes before an external report can strand its lease", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h2-host-capacity-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claimed = JSON.parse(command(repo, ["claim", runId])).execution, invalidations = []; - const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", - result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, - claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, - recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, - outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null })}\n`); - // A reduced bound is a deterministic stand-in for records 252–255; the - // production ceiling remains MAX_JOURNAL_RECORDS. - const constrained = runStore(repo, { journalMaximum: 8, publishProjection(_root, replay) { invalidations.push(replay); } }); - const controller = createLoopController({ store: constrained, repoRoot: repo }); - assert.equal(controller.report(claimed.claimId, report).state, "budget-exhausted"); - const replay = constrained.read(runId); - assert.equal(replay.execution.lease, null); - assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 0); - assert.equal(replay.journal.at(-1).value.type, "terminal-node-committed"); - assert.equal(invalidations.length, 1); - assert.equal(invalidations[0].projection.itemRef, fixtureItemRef); - assert.throws(() => controller.report(claimed.claimId, report), /stale lease/u); -}); - -test("near journal capacity cut after edge retries only the release tail", (t) => { - const directory = mkdtempSync(join(tmpdir(), "h2-host-capacity-cut-")); t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")), runId = created(repo); - const claimed = JSON.parse(command(repo, ["claim", runId])).execution, invalidations = []; - const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", - result: { schema: "agent-result@1", runId: claimed.runId, nodeId: claimed.nodeId, attempt: claimed.attempt, - claimId: claimed.claimId, assignmentId: claimed.assignmentId, invocationId: claimed.invocationId, - recipeRevision: claimed.recipeRevision, policyRevision: claimed.policyRevision, inputCandidate: claimed.inputCandidate, - outcome: "complete", findings: [], resolvedFindingIds: [] }, telemetry: null })}\n`); - const cutStore = runStore(repo, { journalMaximum: 9, hooks: { afterExternalEdgeTaken() { throw new Error("near-capacity cut"); } } }); - assert.throws(() => createLoopController({ store: cutStore, repoRoot: repo }).report(claimed.claimId, report), /near-capacity cut/u); - const restartedStore = runStore(repo, { journalMaximum: 9, publishProjection(_root, replay) { invalidations.push(replay); } }); - assert.equal(createLoopController({ store: restartedStore, repoRoot: repo }).report(claimed.claimId, report).currentNode, "decompose"); - const replay = restartedStore.read(runId); - assert.equal(replay.execution.lease, null); - assert.equal(replay.journal.filter((record) => record.value.type === "external-report-accepted").length, 1); - assert.equal(replay.journal.filter((record) => record.value.type === "edge-taken").length, 1); - assert.equal(replay.journal.filter((record) => record.value.type === "lease-released").length, 1); - assert.equal(invalidations.length, 1); - assert.equal(invalidations[0].projection.itemRef, fixtureItemRef); +test("abandon terminalizes an unfinished provider claim as needs-human", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-abandon-loop-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runId = created(repo), claimed = JSON.parse(command(repo, ["claim", runId])).execution; + const abandoned = JSON.parse(command(repo, + ["abandon", claimed.claimId, "--reason", "host-cancelled"])); + assert.equal(abandoned.state, "needs-human"); }); diff --git a/src/loops/adapters/codex-cli.mjs b/src/loops/adapters/codex-cli.mjs deleted file mode 100644 index 4cf01538..00000000 --- a/src/loops/adapters/codex-cli.mjs +++ /dev/null @@ -1,140 +0,0 @@ -import { spawn as nodeSpawn } from "node:child_process"; -import { TextDecoder } from "node:util"; -import { isAbsolute } from "node:path"; -import { requestedCodexIdentity, validateAgentProfile, validateCodexProbe } from "../agents/profile.mjs"; - -const MAX_OUTPUT_BYTES = 1048576; -// Codex may emit one bounded JSONL item containing a large tool/result payload. -// The aggregate cap is the memory/safety boundary; a smaller per-line cap makes -// valid provider output fail depending on event chunking. -const MAX_JSONL_LINE_BYTES = MAX_OUTPUT_BYTES; -const DIRECT_GUARANTEES = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }); - -function fail(message, code = "ELOOP_CODEX_ADAPTER") { throw Object.assign(new Error(`Codex adapter: ${message}`), { code }); } -function text(value, label, maximum = 262144) { - if (typeof value !== "string" || !value || Buffer.byteLength(value) > maximum || value.includes("\0")) fail(`invalid ${label}`); - return value; -} -function invocation(profile, cwd, prompt) { - const requested = requestedCodexIdentity(profile); - if (typeof cwd !== "string" || !isAbsolute(cwd) || /[\0\r\n]/u.test(cwd)) fail("invalid cwd"); - return Object.freeze({ - command: requested.binary, - args: ["exec", "--json", "--ephemeral", "-m", requested.model, "-c", `model_reasoning_effort=${requested.effort}`, "-s", requested.sandbox, "-C", cwd, "--skip-git-repo-check", "--", text(prompt, "prompt")], - requested, - }); -} -function safeToken(value) { return Number.isSafeInteger(value) && value >= 0; } -function usageFrom(events) { - const event = [...events].reverse().find((item) => item.type === "turn.completed" && item.usage && typeof item.usage === "object" && !Array.isArray(item.usage)); - if (!event) return null; - const usage = event.usage; const cached = usage.cached_input_tokens ?? 0; - if (![usage.input_tokens, usage.output_tokens, cached].every(safeToken) || usage.input_tokens > Number.MAX_SAFE_INTEGER - usage.output_tokens) return null; - return Object.freeze({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cachedInputTokens: cached, totalTokens: usage.input_tokens + usage.output_tokens }); -} -function providerReported(events) { - const event = events.find((item) => item.type === "thread.started" && typeof item.thread_id === "string"); - if (!event || !event.thread_id || Buffer.byteLength(event.thread_id) > 512 || /[\0\r\n]/u.test(event.thread_id)) return null; - const optional = (value) => typeof value === "string" && value && Buffer.byteLength(value) <= 512 && !/[\0\r\n]/u.test(value) ? value : null; - return Object.freeze({ model: optional(event.model), sessionId: event.thread_id, version: optional(event.version) }); -} -function parseJsonl(bytes) { - if (bytes.length > MAX_OUTPUT_BYTES) fail("JSONL output exceeds limit", "ELOOP_CODEX_OUTPUT_LIMIT"); - let source; try { source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { fail("JSONL output is not UTF-8", "ELOOP_CODEX_OUTPUT"); } - const lines = source.split("\n"); - if (lines.at(-1) !== "") fail("JSONL output is not LF terminated", "ELOOP_CODEX_OUTPUT"); - const events = []; - for (const line of lines.slice(0, -1)) { - if (!line || Buffer.byteLength(line) > MAX_JSONL_LINE_BYTES) fail("malformed JSONL output", "ELOOP_CODEX_OUTPUT"); - let value; try { value = JSON.parse(line); } catch { fail("malformed JSONL output", "ELOOP_CODEX_OUTPUT"); } - if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.type !== "string" || !value.type || Buffer.byteLength(value.type) > 128 || /[\0\r\n]/u.test(value.type)) fail("malformed JSONL event", "ELOOP_CODEX_OUTPUT"); - events.push(Object.freeze(value)); - } - return Object.freeze(events); -} -function isolation(value) { - // A direct child close is observable. It is not evidence about descendants, - // so the default controller must never manufacture an empty-process proof. - if (value === undefined) return { guarantees: DIRECT_GUARANTEES, terminate: (child, signal) => child.kill(signal), proveEmpty: async () => false, requireEmptyProof: false }; - const labels = ["freshSession", "filesystemWriteDeny", "foregroundHandle", "cancellation", "lifecycle"]; - if (!value || typeof value !== "object" || !value.guarantees || typeof value.terminate !== "function" - || typeof value.proveEmpty !== "function" || labels.some((key) => !["enforced", "detected-at-boundaries", "supervised", "unsupported"].includes(value.guarantees[key]))) fail("invalid foreground controller"); - return { ...value, requireEmptyProof: value.requireEmptyProof !== false }; -} - -/** A direct foreground process is fresh per call; reviewer write denial remains supervised. */ -export function startCodexInvocation({ profile, cwd, prompt, spawn = nodeSpawn, trustedIsolation }) { - const current = validateAgentProfile(profile); const launch = invocation(current, cwd, prompt); const controller = isolation(trustedIsolation); - let child; - try { - child = spawn(launch.command, launch.args, { cwd, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); - } catch (error) { controller.dispose?.(); throw error; } - if (!child || !Number.isInteger(child.pid) || child.pid <= 0 || !child.stdout || !child.stderr || typeof child.once !== "function") { - child?.once?.("error", () => {}); // consume the deferred spawn error after failing closed synchronously - controller.dispose?.(); - fail("spawn did not return a foreground process handle", "ELOOP_CODEX_HANDLE"); - } - const stdout = []; let outputBytes = 0; let outputExceeded = false; let cancellationRequested = false; let termSent = false; let killSent = false; let settled = false; let finalizing = false; let forceTimer; - const boundedTerminate = () => { - if (!termSent) { try { termSent = controller.terminate(child, "SIGTERM") === true; } catch { termSent = false; } } - if (!forceTimer) forceTimer = setTimeout(() => { if (settled) return; try { killSent = controller.terminate(child, "SIGKILL") === true; } catch { killSent = false; } }, 100); - }; - const capture = (target) => (chunk) => { - const bytes = Buffer.from(chunk); outputBytes += bytes.length; - if (outputBytes > MAX_OUTPUT_BYTES) { outputExceeded = true; boundedTerminate(); return; } - if (target) target.push(bytes); - }; - child.stdout.on("data", capture(stdout)); child.stderr.on("data", capture(null)); - const completion = new Promise((resolve, reject) => { - const finalize = async ({ processError = null, exitCode = null, signal = null }) => { - if (settled || finalizing) return; finalizing = true; clearTimeout(forceTimer); - let empty = false; try { empty = await controller.proveEmpty({ pid: child.pid, exitCode, signal, processError }) === true; } catch { empty = false; } - finally { try { controller.dispose?.(); } catch { empty = false; } } - if (!empty && (controller.requireEmptyProof || cancellationRequested)) { - settled = true; - resolve(Object.freeze({ - requested: launch.requested, providerReported: null, technicallyProven: Object.freeze({ argv: [launch.command, ...launch.args], pidObserved: true }), - guarantees: Object.freeze({ ...controller.guarantees }), events: Object.freeze([]), usage: null, usageStatus: "unavailable", - exitCode: Number.isInteger(exitCode) ? exitCode : null, signal: signal ?? null, cancellationRequested, - termination: Object.freeze({ termSent, killSent, emptyProven: false, descendants: controller.guarantees.lifecycle }), - quarantineRequired: true, outcome: "quarantined", - })); return; - } - try { - if (processError) throw processError; - if (outputExceeded) fail("JSONL output exceeds limit", "ELOOP_CODEX_OUTPUT_LIMIT"); - const events = parseJsonl(Buffer.concat(stdout)); const provider = providerReported(events); const usage = usageFrom(events); - settled = true; - resolve(Object.freeze({ - requested: launch.requested, providerReported: provider, technicallyProven: Object.freeze({ argv: [launch.command, ...launch.args], pidObserved: true }), - guarantees: Object.freeze({ ...controller.guarantees }), events, usage, usageStatus: usage ? "reported" : "unavailable", - exitCode: Number.isInteger(exitCode) ? exitCode : null, signal: signal ?? null, cancellationRequested, - termination: Object.freeze({ termSent, killSent, emptyProven: empty, descendants: controller.guarantees.lifecycle }), - quarantineRequired: false, - outcome: cancellationRequested ? "cancelled" : exitCode === 0 && !signal ? "completed" : "failed", - })); - } catch (error) { settled = true; reject(error); } - }; - child.once("error", (error) => { void finalize({ processError: error }); }); - child.once("close", (exitCode, signal) => { void finalize({ exitCode, signal }); }); - }); - return Object.freeze({ - pid: child.pid, requested: launch.requested, completion, - cancel() { - if (settled || cancellationRequested) return false; cancellationRequested = true; - boundedTerminate(); - return termSent; - }, - }); -} - -/** Child JSONL may report identity/usage, but only the trusted host controller supplies guarantees. */ -export async function probeCodexCli({ profile, cwd, spawn = nodeSpawn, trustedIsolation }) { - const handle = startCodexInvocation({ profile, cwd, spawn, trustedIsolation, prompt: "Burnlist adapter probe. Report provider identity only." }); - const result = await handle.completion; - if (result.outcome !== "completed" || !result.providerReported) fail(`probe did not provide provider identity (${result.outcome})`, "ELOOP_CODEX_PROBE"); - return validateCodexProbe({ - schema: "burnlist-codex-probe@1", requested: result.requested, providerReported: result.providerReported, - technicallyProven: result.technicallyProven, guarantees: { ...result.guarantees, usage: result.usageStatus }, - }); -} diff --git a/src/loops/adapters/codex-cli.test.mjs b/src/loops/adapters/codex-cli.test.mjs deleted file mode 100644 index a7f19c34..00000000 --- a/src/loops/adapters/codex-cli.test.mjs +++ /dev/null @@ -1,132 +0,0 @@ -import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { probeCodexCli, startCodexInvocation } from "./codex-cli.mjs"; -import { resolveStageOneRoutes } from "../agents/profile.mjs"; - -const fakeSource = `#!/usr/bin/env node -const args = process.argv.slice(2); -const mode = process.env.FAKE_MODE || "ok"; -if (mode === "hang") { process.on("SIGTERM",()=>{}); setInterval(() => {}, 1000); } -if (mode === "nonzero") { process.exit(7); } -if (mode === "bad") { process.stdout.write("not-json\\n"); process.exit(0); } -if (mode === "invalid-utf8") { process.stdout.write(Buffer.from([255,10])); process.exit(0); } -if (mode === "oversize") { process.stdout.write(Buffer.alloc(1048577,97)); process.exit(0); } -if (mode === "large-line") { process.stdout.write(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"x".repeat(70000)}})+"\\n"); } -const session = mode === "same-session" ? "same" : "fresh-" + process.pid; -process.stdout.write(JSON.stringify({type:"thread.started",thread_id:session,model:args[args.indexOf("-m") + 1],version:"fake-1"})+"\\n"); -process.stdout.write(JSON.stringify({type:"burnlist.capability",guarantees:{freshSession:"enforced",filesystemWriteDeny:"enforced",foregroundHandle:"enforced",cancellation:"enforced",lifecycle:"enforced"}})+"\\n"); -const usage = mode === "overflow" ? {input_tokens:Number.MAX_SAFE_INTEGER,output_tokens:1} : mode === "missing-usage" ? null : {input_tokens:11,output_tokens:7,cached_input_tokens:3}; -process.stdout.write(JSON.stringify(usage ? {type:"turn.completed",usage} : {type:"turn.completed"})+"\\n"); -`; - -function fixture() { - const directory = mkdtempSync(join(tmpdir(), "burnlist-codex-adapter-")); const binary = join(directory, "fake-codex.mjs"); - writeFileSync(binary, fakeSource, { mode: 0o700 }); chmodSync(binary, 0o700); - return { directory, binary, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; -} -function profile(binary, id, authority, model = "gpt-5.6-terra", effort = "high") { return { schema: "burnlist-loop-agent-profile@1", id, adapter: "builtin:codex-cli", binary, model, effort, authority }; } -function unprovenIsolation() { - return { guarantees: { freshSession: "unsupported", filesystemWriteDeny: "unsupported", foregroundHandle: "supervised", cancellation: "supervised", lifecycle: "unsupported" }, terminate: (child, signal) => signal === "SIGTERM" ? true : child.kill(signal), proveEmpty: async () => false }; -} -function weakButEmptyIsolation() { return { ...unprovenIsolation(), proveEmpty: async () => true }; } -async function withMode(mode, fn) { - const before = process.env.FAKE_MODE; - try { process.env.FAKE_MODE = mode; return await fn(); } - finally { if (before === undefined) delete process.env.FAKE_MODE; else process.env.FAKE_MODE = before; } -} - -test("adapter uses exact ephemeral argv and ignores child capability claims", async () => { - const context = fixture(); - try { - const maker = profile(context.binary, "maker", "write"); - const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Implement." }).completion; - assert.deepEqual(result.technicallyProven.argv.slice(1, -1), ["exec", "--json", "--ephemeral", "-m", "gpt-5.6-terra", "-c", "model_reasoning_effort=high", "-s", "workspace-write", "-C", context.directory, "--skip-git-repo-check", "--"]); - assert.equal(result.technicallyProven.argv.at(-1), "Implement."); assert.equal(result.technicallyProven.pidObserved, true); - assert.deepEqual(result.usage, { inputTokens: 11, outputTokens: 7, cachedInputTokens: 3, totalTokens: 18 }); - - const direct = await startCodexInvocation({ profile: profile(context.binary, "reviewer", "read"), cwd: context.directory, prompt: "Direct." }).completion; - assert.equal(direct.outcome, "completed"); assert.equal(direct.termination.emptyProven, false); - } finally { context.cleanup(); } -}); - -test("cancellation quarantines a closed direct child when descendant exit is unproven", async () => { - const context = fixture(); - try { - await withMode("hang", async () => { - const direct = startCodexInvocation({ profile: profile(context.binary, "maker", "write"), cwd: context.directory, prompt: "Wait.", trustedIsolation: unprovenIsolation() }); - assert.equal(direct.cancel(), true); assert.equal(direct.cancel(), false); const uncertain = await direct.completion; - assert.equal(uncertain.outcome, "quarantined"); assert.equal(uncertain.quarantineRequired, true); assert.equal(uncertain.termination.killSent, true); assert.equal(uncertain.termination.emptyProven, false); - - }); - } finally { context.cleanup(); } -}); - -test("JSONL is strict UTF-8 and usage overflow or absence is unavailable", async () => { - const context = fixture(); - try { - assert.throws(() => startCodexInvocation({ profile: profile(join(context.directory, "missing-codex"), "maker", "write"), cwd: context.directory, prompt: "Missing." }), (error) => error.code === "ELOOP_CODEX_HANDLE"); - const maker = profile(context.binary, "maker", "write"); - await withMode("nonzero", async () => { - const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Nonzero." }).completion; - assert.equal(result.outcome, "failed"); assert.equal(result.exitCode, 7); - }); - await withMode("bad", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); - await withMode("invalid-utf8", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Bad bytes." }).completion, (error) => error.code === "ELOOP_CODEX_OUTPUT")); - await withMode("oversize", async () => assert.rejects(startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Too much." }).completion, (error) => ["ELOOP_CODEX_OUTPUT_LIMIT", "ELOOP_CODEX_OUTPUT"].includes(error.code))); - await withMode("large-line", async () => { - const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Large valid line." }).completion; - assert.equal(result.outcome, "completed"); - assert.equal(result.events[0].item.text.length, 70000); - }); - for (const mode of ["missing-usage", "overflow"]) await withMode(mode, async () => { - const result = await startCodexInvocation({ profile: maker, cwd: context.directory, prompt: "Usage." }).completion; - assert.equal(result.usage, null); assert.equal(result.usageStatus, "unavailable"); - }); - } finally { context.cleanup(); } -}); - -test("each direct reviewer invocation has a fresh foreground PID and provider session", async () => { - const context = fixture(); - try { - const reviewer = profile(context.binary, "reviewer", "read"); - const first = startCodexInvocation({ profile: reviewer, cwd: context.directory, prompt: "Review one." }); - const second = startCodexInvocation({ profile: reviewer, cwd: context.directory, prompt: "Review two." }); - const [left, right] = await Promise.all([first.completion, second.completion]); - assert.notEqual(first.pid, second.pid); - assert.notEqual(left.providerReported.sessionId, right.providerReported.sessionId); - } finally { context.cleanup(); } -}); - -test("route binding requires host-enforced isolation and independent provider sessions", async () => { - const context = fixture(); - try { - const maker = profile(context.binary, "maker", "write"); const reviewer = profile(context.binary, "reviewer", "read", "gpt-5.6-sol", "medium"); - const weakMaker = await probeCodexCli({ profile: maker, cwd: context.directory, trustedIsolation: weakButEmptyIsolation() }); const weakReviewer = await probeCodexCli({ profile: reviewer, cwd: context.directory, trustedIsolation: weakButEmptyIsolation() }); - assert.throws(() => resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: weakMaker, reviewer: weakReviewer } }), (error) => error.code === "ELOOP_REVIEWER_ISOLATION"); - - const strongMaker = await probeCodexCli({ profile: maker, cwd: context.directory }); - const strongReviewer = await probeCodexCli({ profile: reviewer, cwd: context.directory }); - assert.equal(resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: strongMaker, reviewer: strongReviewer } }).review.guarantees.filesystemWriteDeny, "supervised"); - - await withMode("same-session", async () => { - const first = await probeCodexCli({ profile: maker, cwd: context.directory }); - const second = await probeCodexCli({ profile: reviewer, cwd: context.directory }); - assert.throws(() => resolveStageOneRoutes({ profiles: [maker, reviewer], routes: { "implementation.standard": "maker", "review.strong": "reviewer" }, probes: { maker: first, reviewer: second } }), (error) => error.code === "ELOOP_REVIEWER_ISOLATION"); - }); - } finally { context.cleanup(); } -}); - -test("direct foreground controllers are accepted without Docker authority", async () => { - const context = fixture(); - try { - const direct = await startCodexInvocation({ profile: profile(context.binary, "reviewer", "read"), - cwd: context.directory, prompt: "Fake.", trustedIsolation: { - guarantees: { freshSession: "enforced", filesystemWriteDeny: "enforced", foregroundHandle: "enforced", cancellation: "enforced", lifecycle: "enforced" }, - terminate: () => true, proveEmpty: async () => true, - } }).completion; - assert.equal(direct.outcome, "completed"); - } finally { context.cleanup(); } -}); diff --git a/src/loops/adapters/normalized-invocation.mjs b/src/loops/adapters/normalized-invocation.mjs deleted file mode 100644 index 67483391..00000000 --- a/src/loops/adapters/normalized-invocation.mjs +++ /dev/null @@ -1,204 +0,0 @@ -import { startCodexInvocation } from "./codex-cli.mjs"; -import { runTrustedCapability } from "../capabilities/runner.mjs"; -import { parseBoundedObject } from "../contracts/contract.mjs"; -import { validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; - -const MAX_SUMMARY_BYTES = 1024; -const AGENT_RESULT_TYPES = new Set(["complete", "approve", "reject", "escalate"]); -const FINAL_REPORT_KEYS = ["schema", "runId", "nodeId", "attempt", "claimId", "invocationId", "assignmentId", - "recipeRevision", "policyRevision", "inputCandidate", "outcome", "summary", "findings", "resolvedFindingIds"]; -const MAX_FINAL_BYTES = 65_536; -const CANDIDATE = /^cm1-sha256:[a-f0-9]{64}$/u; -const ASSIGNMENT = /^as1-sha256:[a-f0-9]{64}$/u; -const CLAIM = /^cl1-sha256:[a-f0-9]{64}$/u; -const RECIPE = /^er1-sha256:[a-f0-9]{64}$/u; -const POLICY = /^bp1-sha256:[a-f0-9]{64}$/u; -const fail = (message) => { throw Object.assign(new Error(`Loop invocation: ${message}`), { code: "ELOOP_INVOKE" }); }; -const exact = (value, keys) => Boolean(value) && typeof value === "object" && !Array.isArray(value) - && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); - -function clean(value) { - if (typeof value !== "string") return "process failed"; - const bytes = Buffer.from(value.replace(/[\0\r\n]/gu, " "), "utf8"); - let output = bytes.subarray(0, MAX_SUMMARY_BYTES).toString("utf8"); - // Buffer slicing can land in a multibyte code point; trim decoded code - // points until the returned UTF-8 representation is genuinely bounded. - while (Buffer.byteLength(output, "utf8") > MAX_SUMMARY_BYTES) output = Array.from(output).slice(0, -1).join(""); - return output; -} -function route(routes, name) { - const value = routes?.[name]; - if (!value?.profile || typeof value.profile !== "object") fail(`missing ${name} route`); - return value.profile; -} -function binding(value) { - const keys = ["claimId", "assignmentId", "recipeRevision", "policyRevision", "inputCandidate", - "instructionBytes", "itemText", "candidateContext", "reviewerEvidence"]; - if (!exact(value, keys) || !CLAIM.test(value.claimId) || !ASSIGNMENT.test(value.assignmentId) - || !RECIPE.test(value.recipeRevision) || !POLICY.test(value.policyRevision) || !CANDIDATE.test(value.inputCandidate) - || typeof value.instructionBytes !== "string" || !value.instructionBytes || Buffer.byteLength(value.instructionBytes) > 65_536 - || typeof value.itemText !== "string" || !value.itemText || Buffer.byteLength(value.itemText) > 65_536 - || typeof value.candidateContext !== "string" || Buffer.byteLength(value.candidateContext) > 65_536 - || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 - || value.reviewerEvidence.some((item) => typeof item !== "string" || Buffer.byteLength(item) > 4096)) - fail("invalid invocation binding"); - return value; -} -function boundaryCandidate(candidateForBoundary, expected) { - if (typeof candidateForBoundary !== "function") return expected; - const value = candidateForBoundary(); - const id = typeof value === "string" ? value : value?.id; - if (!CANDIDATE.test(id) || id !== expected) fail("candidate changed at evidence boundary"); - return id; -} -function finalPrompt(invocation, node, current) { - return ["Burnlist Stage 1 invocation.", `run=${invocation.runId}`, `node=${node.id}`, `attempt=${invocation.attempt}`, - `claim=${current.claimId}`, `invocation=${invocation.invocationId}`, `assignment=${current.assignmentId}`, - `recipe=${current.recipeRevision}`, `policy=${current.policyRevision}`, `candidate=${current.inputCandidate}`, - `role=${node.role ?? "check"}`, - "Burnlist owns graph routing and checks; host output is transport-only and idempotent by replaying the exact final report.", - "Report exactly one canonical object with this schema: burnlist.agent-final@1", - "Required report fields: schema, runId, nodeId, attempt, claimId, invocationId, assignmentId, recipeRevision, policyRevision, inputCandidate, outcome, summary, findings, resolvedFindingIds.", - "Do not include destination or any graph transitions; no host-selected edges are accepted.", - "Host result contracts are by node mode: task=>complete, review=>approve|reject|escalate.", - "Host claim envelopes are bounded; use exact prepared claim identity and expiry semantics from the transport contract.", - "Host telemetry is optional but, if provided, must use burnlist-loop-host-telemetry@1 with provenance=host-reported and non-negative token/time fields.", - "FROZEN INSTRUCTIONS:", current.instructionBytes, "ASSIGNED ITEM:", current.itemText, - "CANDIDATE CONTEXT:", current.candidateContext, "REVIEW EVIDENCE:", JSON.stringify(current.reviewerEvidence), - "OPEN FINDINGS:", JSON.stringify(current.openFindings ?? [])].join("\n"); -} -function finalTexts(events) { - const texts = []; - const finals = []; - for (const event of events) { - if (event?.type !== "item.completed" || !event.item || typeof event.item !== "object" || Array.isArray(event.item) - || event.item.type !== "agent_message" || typeof event.item.text !== "string") continue; - const text = event.item.text; - try { - const value = parseBoundedObject(Buffer.from(text, "utf8"), { maximumBytes: MAX_FINAL_BYTES, maximumDepth: 2, label: "agent final" }); - if (value.schema === "burnlist.agent-final@1") finals.push(text); - } catch { - // preceding conversational messages are permitted. - } - texts.push(text); - } - if (!texts.length) fail("agent emitted no final message"); - return { texts, finals }; -} -function agentResult(events, invocation, node, current) { - const { texts, finals } = finalTexts(events); - if (!finals.length) fail("malformed or missing terminal agent result"); - if (!finals.every((value) => value === finals[0])) fail("duplicate terminal reports differ"); - const envelopes = []; - for (let index = 0; index < texts.length; index += 1) { - try { - const value = parseBoundedObject(Buffer.from(texts[index], "utf8"), { maximumBytes: MAX_FINAL_BYTES, maximumDepth: 2, label: "agent final" }); - if (value.schema === "burnlist.agent-final@1") envelopes.push({ index, value }); - } catch { - // non-final conversational lines are tolerated. - } - } - if (envelopes.at(-1).index !== texts.length - 1 || envelopes.length !== finals.length) fail("malformed or ambiguous terminal agent result"); - const result = envelopes[0].value; - if (!exact(result, FINAL_REPORT_KEYS) || result.schema !== "burnlist.agent-final@1" || result.runId !== invocation.runId - || result.nodeId !== node.id || result.attempt !== invocation.attempt || result.invocationId !== invocation.invocationId - || result.claimId !== current.claimId || result.assignmentId !== current.assignmentId - || result.recipeRevision !== current.recipeRevision || result.policyRevision !== current.policyRevision - || result.inputCandidate !== current.inputCandidate - || !AGENT_RESULT_TYPES.has(result.outcome) || typeof result.summary !== "string") fail("malformed or stale agent result"); - const allowed = node.mode === "task" ? result.outcome === "complete" : ["approve", "reject", "escalate"].includes(result.outcome); - if (!allowed) fail("agent result is illegal for node"); - return { kind: result.outcome, summary: clean(result.summary), outputBytes: Buffer.byteLength(texts.at(-1), "utf8"), - findings: result.findings, resolvedFindingIds: result.resolvedFindingIds }; -} -function boundedCompletion(handle, milliseconds) { - if (!milliseconds) return handle.completion.then((value) => ({ value, timedOut: false })); - let timer; - const timeout = new Promise((resolve) => { - timer = setTimeout(async () => { - try { handle.cancel(); } catch { /* a broken controller is still bounded */ } - const forced = await Promise.race([ - Promise.resolve(handle.completion).then((value) => ({ value }), () => ({ value: null })), - new Promise((done) => setTimeout(() => done(null), 500)), - ]); - resolve(forced ? { ...forced, timedOut: true } : { value: null, cleanupLost: true }); - }, milliseconds); - }); - // Attaching both handlers avoids a later rejected completion becoming an - // unhandled rejection after the bounded timeout result has been returned. - const settled = Promise.resolve(handle.completion).then((value) => ({ value, timedOut: false }), () => ({ value: null, timedOut: false, failed: true })); - return Promise.race([settled, timeout]).finally(() => clearTimeout(timer)); -} - -/** - * M3 adapter seam for M2's normalized invoke callback. `bindingFor` provides - * the immutable assignment/candidate pair that the M2 invocation shape lacks; - * a final cannot be accepted without both values matching exactly. - */ -export function createNormalizedInvocation({ repoRoot, routes, nodes, bindingFor, candidateForBoundary = null, - startAgent = startCodexInvocation, runCheck = runTrustedCapability, agentTimeoutMs = 0 }) { - if (typeof repoRoot !== "string" || !repoRoot.startsWith("/") || !(nodes instanceof Map) || typeof bindingFor !== "function" - || !(candidateForBoundary === null || typeof candidateForBoundary === "function") - || typeof startAgent !== "function" || typeof runCheck !== "function" || !Number.isSafeInteger(agentTimeoutMs) - || agentTimeoutMs < 0 || agentTimeoutMs > 86_400_000) fail("invalid dispatcher input"); - let active = null; - async function invokeBound(invocation, node, current, requireReviewEvidence = true) { - if (node.mode === "review" && (!current.candidateContext || requireReviewEvidence && !current.reviewerEvidence.length)) - return Object.freeze({ kind: "error", summary: "review context is incomplete", outputBytes: 0 }); - if (node.kind === "check") { - try { - boundaryCandidate(candidateForBoundary, current.inputCandidate); - const checked = await runCheck({ repoRoot, capabilityId: node.capability, inputCandidate: current.inputCandidate }); - boundaryCandidate(candidateForBoundary, current.inputCandidate); - if (checked?.result?.inputCandidate !== current.inputCandidate || !["pass", "fail"].includes(checked?.result?.outcome)) fail("stale trusted check result"); - const summary = checked.result.timedOut ? "repository check timed out" : checked.result.truncated ? "repository check output limit" : `repository check ${checked.result.outcome}`; - return Object.freeze({ kind: checked.result.timedOut ? "timeout" : checked.result.outcome, summary, outputBytes: Buffer.isBuffer(checked.evidence) ? checked.evidence.length : 0 }); - } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } - } - const profile = route(routes, node.mode === "review" ? "review" : "implementation"); - try { - if (node.mode === "review") boundaryCandidate(candidateForBoundary, current.inputCandidate); - const handle = startAgent({ profile, cwd: repoRoot, prompt: finalPrompt(invocation, node, current) }); - if (!handle || typeof handle.cancel !== "function" || !handle.completion || typeof handle.completion.then !== "function") fail("agent did not return a foreground handle"); - active = handle; - const completed = await boundedCompletion(handle, agentTimeoutMs); - if (completed.cleanupLost) return Object.freeze({ kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); - if (completed.value?.outcome === "quarantined") return Object.freeze({ kind: "lost", summary: "agent process cleanup unproven", outputBytes: 0 }); - if (completed.timedOut) return Object.freeze({ kind: "timeout", summary: "agent deadline exceeded", outputBytes: 0 }); - if (completed.failed) return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); - if (completed.value.outcome === "cancelled") return Object.freeze({ kind: "cancelled", summary: "agent cancelled", outputBytes: 0 }); - if (completed.value.outcome !== "completed") return Object.freeze({ kind: "error", summary: "agent process failed", outputBytes: 0 }); - if (node.mode === "review") boundaryCandidate(candidateForBoundary, current.inputCandidate); - return Object.freeze(agentResult(completed.value.events, invocation, node, current)); - } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } - finally { active = null; } - } - async function invoke(invocation) { - const node = nodes.get(invocation?.nodeId); - if (!invocation || typeof invocation !== "object" || !node || typeof node !== "object") fail("invalid invocation"); - let current; - try { current = binding(bindingFor(invocation, node)); } catch (error) { return Object.freeze({ kind: "error", summary: clean(error?.message), outputBytes: 0 }); } - const result = await invokeBound(invocation, node, current); - const { findings: _findings, resolvedFindingIds: _resolved, ...normalized } = result; - return Object.freeze(normalized); - } - /** Executes the exact controller-prepared agent input; no second prompt binding is invented. */ - async function invokePrepared(envelopeBytes) { - const envelope = validateHostExecutionEnvelope(envelopeBytes), input = envelope.input.value; - const node = nodes.get(input.nodeId); - if (!node || node.kind !== "agent") fail("prepared invocation does not target an agent node"); - const current = { - claimId: input.claimId, assignmentId: input.assignmentId, recipeRevision: input.recipeRevision, - policyRevision: input.policyRevision, inputCandidate: input.inputCandidate, - instructionBytes: Buffer.from(input.instructionBytes, "base64").toString("utf8"), - itemText: input.itemText ? Buffer.from(input.itemText, "base64").toString("utf8") : "legacy prepared claim", - candidateContext: Buffer.from(input.candidateContext, "base64").toString("utf8"), reviewerEvidence: input.reviewerEvidence, - openFindings: input.openFindings ?? [], - }; - return invokeBound(input, node, current); - } - Object.defineProperty(invoke, "cancel", { value: () => active?.cancel?.() === true, enumerable: false }); - Object.defineProperty(invoke, "active", { get: () => active !== null, enumerable: false }); - Object.defineProperty(invoke, "invokePrepared", { value: invokePrepared, enumerable: false }); - return invoke; -} diff --git a/src/loops/adapters/normalized-invocation.test.mjs b/src/loops/adapters/normalized-invocation.test.mjs deleted file mode 100644 index 76b276d2..00000000 --- a/src/loops/adapters/normalized-invocation.test.mjs +++ /dev/null @@ -1,143 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { createNormalizedInvocation } from "./normalized-invocation.mjs"; -import { createDispatchAuthority, createInvocationInput } from "../contracts/agent-result.mjs"; -import { createHostExecutionEnvelope } from "../contracts/host-execution.mjs"; -import { rawSha256 } from "../dsl/hash.mjs"; - -const profile = { id: "fake" }; -const routes = { implementation: { profile }, review: { profile: { id: "review" } } }; -const call = Object.freeze({ runId: "run:01arz3ndektsv4rrffq69g5fav", nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) }); -const maker = Object.freeze({ id: "implement", kind: "agent", mode: "task", execution: "managed", intelligence: "standard", role: "maker", instructions: "make" }); -const reviewer = Object.freeze({ id: "review", kind: "agent", mode: "review", execution: "managed", intelligence: "strong", role: "reviewer", instructions: "review" }); -const check = Object.freeze({ id: "verify", kind: "check", capability: "repo-verify" }); -const current = Object.freeze({ - claimId: `cl1-sha256:${"1".repeat(64)}`, assignmentId: `as1-sha256:${"b".repeat(64)}`, - recipeRevision: `er1-sha256:${"2".repeat(64)}`, policyRevision: `bp1-sha256:${"3".repeat(64)}`, - inputCandidate: `cm1-sha256:${"a".repeat(64)}`, instructionBytes: "Do the frozen work.\n", - itemText: "- [ ] M3 | Connect processes\n", candidateContext: "candidate-v1\n", reviewerEvidence: ["verify:pass"], -}); -function final(invocation, node, outcome = "complete", extra = {}) { - return JSON.stringify({ schema: "burnlist.agent-final@1", runId: invocation.runId, nodeId: node.id, attempt: invocation.attempt, - claimId: current.claimId, invocationId: invocation.invocationId, assignmentId: current.assignmentId, - recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, - inputCandidate: current.inputCandidate, outcome, summary: "ok", findings: [], resolvedFindingIds: [], ...extra }); -} -function event(...texts) { return texts.map((text) => ({ type: "item.completed", item: { type: "agent_message", text } })); } -function agent(events, outcome = "completed") { return () => ({ cancel() { return true; }, completion: Promise.resolve({ outcome, events }) }); } -function dispatcher({ startAgent, runCheck, timeout = 0, bindingFor = () => current, candidateForBoundary = null } = {}) { - return createNormalizedInvocation({ repoRoot: "/repo", routes, nodes: new Map([[maker.id, maker], [reviewer.id, reviewer], [check.id, check]]), bindingFor, - candidateForBoundary, startAgent: startAgent ?? agent(event(final(call, maker))), runCheck: runCheck ?? (async () => ({ result: { outcome: "pass", inputCandidate: current.inputCandidate, timedOut: false, truncated: false }, evidence: Buffer.from("check") })), agentTimeoutMs: timeout }); -} - -function preparedReview(reviewerEvidence) { - const binding = { runId: call.runId, nodeId: "review", attempt: 1, claimId: current.claimId, assignmentId: current.assignmentId, - invocationId: `iv1-sha256:${"c".repeat(64)}`, recipeRevision: current.recipeRevision, policyRevision: current.policyRevision, - inputCandidate: current.inputCandidate }; - const instruction = Buffer.from(current.instructionBytes), input = createInvocationInput({ schema: "burnlist-loop-invocation-input@1", ...binding, - itemRevision: `id1-sha256:${"d".repeat(64)}`, execution: reviewer.execution, intelligence: reviewer.intelligence, instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), - mode: "review", role: "reviewer", authority: "read", legalOutcomes: ["approve", "reject", "escalate"], requires: [], - itemText: Buffer.from(current.itemText).toString("base64"), - candidateContext: Buffer.from(current.candidateContext).toString("base64"), reviewerEvidence }); - const authority = createDispatchAuthority({ schema: "burnlist-loop-dispatch-authority@1", state: "prepared-before-dispatch", ...binding, - itemRevision: input.value.itemRevision, inputSchema: input.value.schema, inputDigest: input.digest, inputByteLength: input.bytes.length }); - return createHostExecutionEnvelope({ schema: "burnlist-loop-host-execution@1", ...binding, issuedAt: 1, expiresAt: 3_601_000, - invocationInput: input.bytes.toString("base64"), dispatchAuthority: authority.bytes.toString("base64") }).bytes; -} - -test("maps actual Codex agent-message finals for maker and fresh reviewer", async () => { - const seen = []; - const invoke = dispatcher({ startAgent: ({ profile: selected, prompt }) => { - seen.push(selected.id); const node = selected.id === "review" ? reviewer : maker; const invocationId = /invocation=([a-f0-9]{32})/u.exec(prompt)[1]; - assert.match(prompt, /FROZEN INSTRUCTIONS:\nDo the frozen work\.\n/u); - assert.match(prompt, /ASSIGNED ITEM:\n- \[ \] M3 \| Connect processes\n/u); - return { cancel() { return true; }, completion: Promise.resolve({ outcome: "completed", - events: event("Working notes before the terminal envelope.", final({ ...call, nodeId: node.id, invocationId }, node, node === reviewer ? "reject" : "complete")) }) }; - } }); - assert.equal((await invoke(call)).kind, "complete"); - assert.equal((await invoke({ ...call, nodeId: "review", invocationId: "c".repeat(32) })).kind, "reject"); - assert.deepEqual(seen, ["fake", "review"]); -}); - -test("budgets only the accepted terminal envelope, not provider event telemetry", async () => { - const envelope = final(call, maker); - const invoke = dispatcher({ startAgent: agent([ - { type: "provider.telemetry", payload: "x".repeat(300_000) }, - ...event(envelope), - ]) }); - const result = await invoke(call); - assert.equal(result.kind, "complete"); - assert.equal(result.outputBytes, Buffer.byteLength(envelope, "utf8")); -}); - -test("rejects malformed or stale claim, revision, candidate, assignment, and invocation finals", async () => { - for (const extra of [{ invocationId: "b".repeat(32) }, { claimId: `cl1-sha256:${"9".repeat(64)}` }, - { recipeRevision: `er1-sha256:${"8".repeat(64)}` }, { policyRevision: `bp1-sha256:${"7".repeat(64)}` }, - { inputCandidate: `cm1-sha256:${"c".repeat(64)}` }, { assignmentId: `as1-sha256:${"d".repeat(64)}` }]) { - const invoke = dispatcher({ startAgent: agent(event(final(call, maker, "complete", extra))) }); - assert.equal((await invoke(call)).kind, "error"); - } - assert.equal((await dispatcher({ startAgent: agent(event("not json")) })(call)).kind, "error"); - assert.equal((await dispatcher({ startAgent: agent(event(final(call, maker), final(call, maker))) })(call)).kind, "complete"); - assert.equal((await dispatcher({ bindingFor: () => ({ inputCandidate: current.inputCandidate }) })(call)).kind, "error"); -}); - -test("rejects duplicate terminal reports when report bytes differ", async () => { - const first = dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "first" }))) }) ; - const mismatch = dispatcher({ - startAgent: agent(event(final(call, maker, "complete", { summary: "first" }), final(call, maker, "complete", { summary: "second" }))), - })(call); - assert.equal((await first(call)).kind, "complete"); - assert.equal((await mismatch).kind, "error"); -}); - -test("maps trusted checks including timeout and stale candidate without invoking an agent", async () => { - const verify = { ...call, nodeId: "verify" }; - assert.equal((await dispatcher()(verify)).kind, "pass"); - const timeout = await dispatcher({ runCheck: async () => ({ result: { outcome: "fail", inputCandidate: current.inputCandidate, timedOut: true, truncated: false }, evidence: Buffer.from("x") }) })(verify); - assert.equal(timeout.kind, "timeout"); - const stale = await dispatcher({ runCheck: async () => ({ result: { outcome: "pass", inputCandidate: `cm1-sha256:${"f".repeat(64)}`, timedOut: false, truncated: false }, evidence: Buffer.alloc(0) }) })(verify); - assert.equal(stale.kind, "error"); -}); - -test("invalidates check and review evidence when the worktree drifts at a boundary", async () => { - const verify = { ...call, nodeId: "verify" }; - const changed = `cm1-sha256:${"f".repeat(64)}`; - let captures = 0; - const checkResult = await dispatcher({ - candidateForBoundary: () => ({ id: captures++ === 0 ? current.inputCandidate : changed }), - })(verify); - assert.equal(checkResult.kind, "error"); - captures = 0; - const reviewResult = await dispatcher({ - candidateForBoundary: () => ({ id: captures++ === 0 ? current.inputCandidate : changed }), - startAgent: agent(event(final({ ...call, nodeId: "review", invocationId: "c".repeat(32) }, reviewer, "approve"))), - })({ ...call, nodeId: "review", invocationId: "c".repeat(32) }); - assert.equal(reviewResult.kind, "error"); -}); - -test("agent deadline becomes lost if cancellation never settles", async () => { - const invoke = dispatcher({ timeout: 10, startAgent: () => ({ cancel() { return true; }, completion: new Promise(() => {}) }) }); - assert.deepEqual(await invoke(call), { kind: "lost", summary: "agent deadline cleanup unproven", outputBytes: 0 }); -}); - -test("prepared review dispatch keeps envelope evidence enforcement and exposes exact evidence", async () => { - const evidence = `artifact:sha256:${"e".repeat(64)}`; - let spawned = false; - const missing = dispatcher({ startAgent() { spawned = true; throw new Error("must not start"); } }); - assert.equal((await missing.invokePrepared(preparedReview([]))).kind, "error"); - assert.equal(spawned, false); - let prompt = ""; - const invoke = dispatcher({ startAgent: ({ prompt: value }) => { - prompt = value; - return { cancel() { return true; }, completion: Promise.resolve({ outcome: "cancelled", events: [] }) }; - } }); - assert.equal((await invoke.invokePrepared(preparedReview([evidence]))).kind, "cancelled"); - assert.match(prompt, new RegExp(evidence, "u")); -}); - -test("summaries are valid UTF-8 and limited to 1024 bytes", async () => { - const result = await dispatcher({ startAgent: agent(event(final(call, maker, "complete", { summary: "€".repeat(500) }))) })(call); - assert.ok(Buffer.byteLength(result.summary, "utf8") <= 1024); - assert.doesNotThrow(() => Buffer.from(result.summary, "utf8").toString("utf8")); -}); diff --git a/src/loops/agents/profile.mjs b/src/loops/agents/profile.mjs deleted file mode 100644 index d5ebd1e6..00000000 --- a/src/loops/agents/profile.mjs +++ /dev/null @@ -1,128 +0,0 @@ -import { isAbsolute } from "node:path"; -import { prefixed } from "../dsl/hash.mjs"; - -export const AGENT_PROFILE_SCHEMA = "burnlist-loop-agent-profile@1"; -export const STAGE_ONE_ROUTES = Object.freeze(["implementation.standard", "review.strong"]); -export const CODEX_MODELS = Object.freeze(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark"]); -export const REASONING_EFFORTS = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max"]); - -const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -const controls = /[\0\r\n]/u; -const guarantee = new Set(["enforced", "detected-at-boundaries", "supervised", "unsupported"]); -const requiredReviewerGuarantees = Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised" }); - -function fail(message, code = "ELOOP_AGENT_PROFILE") { throw Object.assign(new Error(`Loop agent: ${message}`), { code }); } -function exact(value, keys) { return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); } -function text(value, label, maximum = 4096) { - if (typeof value !== "string" || !value || Buffer.byteLength(value) > maximum || controls.test(value)) fail(`invalid ${label}`); - return value; -} -function identity(value, label) { - if (!exact(value, ["adapter", "binary", "model", "effort", "sandbox"])) fail(`invalid ${label} identity`); - if (value.adapter !== "builtin:codex-cli" || !isAbsolute(text(value.binary, `${label} binary`)) || !CODEX_MODELS.includes(value.model) || !REASONING_EFFORTS.includes(value.effort) || !["workspace-write", "read-only"].includes(value.sandbox)) fail(`invalid ${label} identity`); - return { adapter: value.adapter, binary: value.binary, model: value.model, effort: value.effort, sandbox: value.sandbox }; -} -function guarantees(value) { - const keys = ["freshSession", "filesystemWriteDeny", "foregroundHandle", "cancellation", "lifecycle", "usage"]; - if (!exact(value, keys) || keys.some((key) => key === "usage" ? !["reported", "unavailable"].includes(value[key]) : !guarantee.has(value[key]))) fail("invalid probe guarantees"); - return Object.fromEntries(keys.map((key) => [key, value[key]])); -} -function validInvocation(requested, argv) { - return Array.isArray(argv) && argv.length === 15 - && argv[0] === requested.binary && argv[1] === "exec" && argv[2] === "--json" && argv[3] === "--ephemeral" - && argv[4] === "-m" && argv[5] === requested.model && argv[6] === "-c" && argv[7] === `model_reasoning_effort=${requested.effort}` - && argv[8] === "-s" && argv[9] === requested.sandbox && argv[10] === "-C" - && typeof argv[11] === "string" && isAbsolute(argv[11]) && !controls.test(argv[11]) - && argv[12] === "--skip-git-repo-check" && typeof argv[13] === "string" && argv[13] === "--" - && typeof argv[14] === "string" && argv[14].length > 0 && Buffer.byteLength(argv[14]) <= 262144 && !argv[14].includes("\0"); -} - -/** Closed local profile. It requests an identity and authority but proves neither. */ -export function validateAgentProfile(value) { - const keys = ["schema", "id", "adapter", "binary", "model", "effort", "authority"]; - if (!exact(value, keys) || value.schema !== AGENT_PROFILE_SCHEMA || !slug.test(value.id)) fail("invalid profile"); - if (value.adapter !== "builtin:codex-cli" || !isAbsolute(text(value.binary, "binary")) || !["read", "write"].includes(value.authority)) fail("invalid profile"); - if (!CODEX_MODELS.includes(value.model)) fail(`model must be one of: ${CODEX_MODELS.join(", ")}`); - if (!REASONING_EFFORTS.includes(value.effort)) fail(`effort must be one of: ${REASONING_EFFORTS.join(", ")}`); - return Object.freeze({ schema: value.schema, id: value.id, adapter: value.adapter, binary: value.binary, model: value.model, effort: value.effort, authority: value.authority }); -} - -/** Provider statements and host-observed invocation evidence remain separate. */ -export function validateCodexProbe(value) { - if (!exact(value, ["schema", "requested", "providerReported", "technicallyProven", "guarantees"]) || value.schema !== "burnlist-codex-probe@1") fail("invalid Codex probe"); - const requested = identity(value.requested, "requested"); - if (!exact(value.providerReported, ["model", "sessionId", "version"]) || typeof value.providerReported.sessionId !== "string" || !value.providerReported.sessionId || [value.providerReported.model, value.providerReported.version].some((item) => item !== null && typeof item !== "string") || [value.providerReported.sessionId, value.providerReported.model, value.providerReported.version].filter((item) => item !== null).some((item) => !item || Buffer.byteLength(item) > 512 || controls.test(item))) fail("invalid provider-reported identity"); - if (!exact(value.technicallyProven, ["argv", "pidObserved"]) || value.technicallyProven.pidObserved !== true || !validInvocation(requested, value.technicallyProven.argv)) fail("invalid technically-proven identity"); - return Object.freeze({ - schema: value.schema, - requested, - providerReported: { model: value.providerReported.model, sessionId: value.providerReported.sessionId, version: value.providerReported.version }, - technicallyProven: { argv: [...value.technicallyProven.argv], pidObserved: true }, - guarantees: guarantees(value.guarantees), - }); -} - -export function requestedCodexIdentity(profile) { - const current = validateAgentProfile(profile); - return Object.freeze({ adapter: current.adapter, binary: current.binary, model: current.model, effort: current.effort, sandbox: current.authority === "write" ? "workspace-write" : "read-only" }); -} -export function agentProfileRevision(profile) { - const current = validateAgentProfile(profile); - return prefixed("ap1-sha256:", "agent-profile-v1", [Buffer.from(`${JSON.stringify(current)}\n`)]); -} -function sameIdentity(left, right) { return left.adapter === right.adapter && left.binary === right.binary && left.model === right.model && left.effort === right.effort && left.sandbox === right.sandbox; } -function routeMap(value) { - if (!exact(value, STAGE_ONE_ROUTES)) fail("Stage 1 routes must be closed"); - for (const route of STAGE_ONE_ROUTES) if (!slug.test(value[route])) fail(`invalid ${route} route`); - return value; -} - -/** Resolve M1 configuration authority without launching or probing an agent. */ -export function resolveConfiguredStageOneRoutes({ profiles, routes }) { - if (!Array.isArray(profiles) || profiles.length < 2 || profiles.length > 32) fail("invalid profiles"); - const byId = new Map(); - for (const profile of profiles.map(validateAgentProfile)) { - if (byId.has(profile.id)) fail("duplicate profile id"); - byId.set(profile.id, profile); - } - const mapped = routeMap(routes); - if (mapped["implementation.standard"] === mapped["review.strong"]) fail("implementation and review require distinct profile ids", "ELOOP_REVIEWER_ISOLATION"); - const resolveRoute = (route, authority) => { - const profile = byId.get(mapped[route]); - if (!profile) fail(`route ${route} references an unknown profile`); - if (profile.authority !== authority) fail(`route ${route} requires ${authority} authority`); - return Object.freeze({ route, profile, authority: profile.authority }); - }; - const implementation = resolveRoute("implementation.standard", "write"); - const review = resolveRoute("review.strong", "read"); - return Object.freeze({ - implementation: Object.freeze({ ...implementation, guarantees: Object.freeze({}) }), - review: Object.freeze({ ...review, guarantees: Object.freeze({ freshSession: "enforced", filesystemWriteDeny: "supervised" }) }), - }); -} - -/** Bind only the two Stage 1 routes; hard reviewer guarantees never downgrade. */ -export function resolveStageOneRoutes({ profiles, routes, probes }) { - if (!Array.isArray(profiles) || profiles.length < 2 || profiles.length > 32) fail("invalid profiles"); - const byId = new Map(); - for (const profile of profiles.map(validateAgentProfile)) { - if (byId.has(profile.id)) fail("duplicate profile id"); - byId.set(profile.id, profile); - } - const mapped = routeMap(routes); - if (!probes || typeof probes !== "object" || Array.isArray(probes)) fail("invalid probe map"); - const resolveRoute = (route, authority) => { - const profile = byId.get(mapped[route]); - if (!profile) fail(`route ${route} references an unknown profile`); - if (profile.authority !== authority) fail(`route ${route} requires ${authority} authority`); - const probe = validateCodexProbe(probes[profile.id]); const requested = requestedCodexIdentity(profile); - if (!sameIdentity(probe.requested, requested) || probe.providerReported.model !== null && probe.providerReported.model !== requested.model) fail(`probe does not bind profile ${profile.id}`); - return Object.freeze({ route, profile, requested, providerReported: probe.providerReported, technicallyProven: probe.technicallyProven, guarantees: probe.guarantees }); - }; - const implementation = resolveRoute("implementation.standard", "write"); - const review = resolveRoute("review.strong", "read"); - if (implementation.profile.id === review.profile.id || implementation.providerReported.sessionId === review.providerReported.sessionId) fail("implementation and review require independent provider sessions", "ELOOP_REVIEWER_ISOLATION"); - for (const [name, expected] of Object.entries(requiredReviewerGuarantees)) - if (review.guarantees[name] !== expected) fail(`review route lacks ${expected} ${name}`, "ELOOP_REVIEWER_ISOLATION"); - return Object.freeze({ implementation, review }); -} diff --git a/src/loops/agents/profile.test.mjs b/src/loops/agents/profile.test.mjs deleted file mode 100644 index 4c9af72c..00000000 --- a/src/loops/agents/profile.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { requestedCodexIdentity, resolveConfiguredStageOneRoutes, validateAgentProfile, validateCodexProbe } from "./profile.mjs"; - -const profile = { schema: "burnlist-loop-agent-profile@1", id: "reviewer", adapter: "builtin:codex-cli", binary: "/usr/local/bin/codex", model: "gpt-5.6-sol", effort: "medium", authority: "read" }; -const requested = requestedCodexIdentity(profile); -const argv = [profile.binary, "exec", "--json", "--ephemeral", "-m", profile.model, "-c", "model_reasoning_effort=medium", "-s", "read-only", "-C", "/tmp/repo", "--skip-git-repo-check", "--", "Review."]; - -test("agent profiles are closed and request a role-specific sandbox", () => { - assert.deepEqual(requested, { adapter: "builtin:codex-cli", binary: profile.binary, model: profile.model, effort: profile.effort, sandbox: "read-only" }); - assert.throws(() => validateAgentProfile({ ...profile, unknown: true }), /invalid profile/u); - assert.throws(() => validateAgentProfile({ ...profile, binary: "codex" }), /invalid profile/u); -}); - -test("probe contract rejects non-ephemeral or identity-mismatched launch proof", () => { - const value = { schema: "burnlist-codex-probe@1", requested, providerReported: { model: profile.model, sessionId: "provider-session", version: "1.2.3" }, technicallyProven: { argv, pidObserved: true }, guarantees: { freshSession: "enforced", filesystemWriteDeny: "enforced", foregroundHandle: "enforced", cancellation: "enforced", lifecycle: "enforced", usage: "unavailable" } }; - assert.deepEqual(validateCodexProbe(value).technicallyProven.argv, argv); - assert.equal(validateCodexProbe({ ...value, providerReported: { model: null, sessionId: "provider-session", version: null } }).providerReported.model, null); - assert.throws(() => validateCodexProbe({ ...value, technicallyProven: { ...value.technicallyProven, argv: argv.filter((item) => item !== "--ephemeral") } }), /technically-proven/u); - assert.throws(() => validateCodexProbe({ ...value, requested: { ...requested, model: "gpt-5.6-terra" } }), /technically-proven/u); -}); - -test("configured Stage 1 routes have exact names, distinct profiles, and honest M1 guarantees", () => { - const maker = { ...profile, id: "maker", authority: "write" }; - const resolved = resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "maker", "review.strong": "reviewer" } }); - assert.equal(resolved.implementation.profile.id, "maker"); - assert.equal(resolved.implementation.authority, "write"); - assert.equal(resolved.review.profile.id, "reviewer"); - assert.deepEqual(resolved.review.guarantees, { freshSession: "enforced", filesystemWriteDeny: "supervised" }); - assert.throws(() => resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "maker", "review.strong": "maker" } }), /distinct profile ids/u); - assert.throws(() => resolveConfiguredStageOneRoutes({ profiles: [maker, profile], routes: { "implementation.standard": "reviewer", "review.strong": "maker" } }), /requires write authority/u); -}); diff --git a/src/loops/completion/completion.test.mjs b/src/loops/completion/completion.test.mjs index 09db019b..16e29f78 100644 --- a/src/loops/completion/completion.test.mjs +++ b/src/loops/completion/completion.test.mjs @@ -5,7 +5,9 @@ import { join } from "node:path"; import test from "node:test"; import { burnItem, closeLifecycle } from "../../cli/lifecycle-moves.mjs"; import { prepareItemMutation, unassignLoopItem } from "../assignment/assignment.mjs"; -import { createProductionRun, createStoredProductionRunRunner } from "../run/binder.mjs"; +import { createProductionRun, createStoredSystemRunRunner } from "../run/binder.mjs"; +import { createLoopController } from "../run/controller.mjs"; +import { validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "../run/run-test-fixtures.mjs"; import { runStore } from "../run/run-store.mjs"; import { completeLoopRun } from "./completion.mjs"; @@ -25,15 +27,21 @@ function unassignedContext(t) { async function converged(context, runId = fixtureRunId) { const store = runStore(context.repo); await createProductionRun({ repoRoot: context.repo, store, itemRef: fixtureItemRef, runId }); - const counter = join(context.directory, `counter-${runId.slice(-4)}`); writeFileSync(counter, "0"); - const before = [process.env.BURNLIST_FAKE_COUNTER, process.env.BURNLIST_FAKE_OUTCOMES]; - process.env.BURNLIST_FAKE_COUNTER = counter; process.env.BURNLIST_FAKE_OUTCOMES = "complete,complete,complete,approve,complete,approve"; - try { assert.equal((await createStoredProductionRunRunner({ repoRoot: context.repo, store, runId }).run()).projection.state, "converged"); } - finally { - for (const [key, value] of [["BURNLIST_FAKE_COUNTER", before[0]], ["BURNLIST_FAKE_OUTCOMES", before[1]]]) { - if (value === undefined) delete process.env[key]; else process.env[key] = value; - } + const controller = createLoopController({ store, repoRoot: context.repo, + runnerFor: (id) => createStoredSystemRunRunner({ repoRoot: context.repo, store, runId: id }) }); + for (let attempts = 0; attempts < 8 && !store.read(runId).execution.terminal; attempts += 1) { + const claimed = controller.claim(runId), execution = validateHostExecutionEnvelope(claimed.envelope).value; + const outcome = store.read(runId).execution.node.mode === "task" ? "complete" : "approve"; + const report = Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", runId: execution.runId, nodeId: execution.nodeId, + attempt: execution.attempt, claimId: execution.claimId, assignmentId: execution.assignmentId, + invocationId: execution.invocationId, recipeRevision: execution.recipeRevision, + policyRevision: execution.policyRevision, inputCandidate: execution.inputCandidate, + outcome, findings: [], resolvedFindingIds: [], + }, telemetry: null })}\n`); + await controller.report(execution.claimId, report); } + assert.equal(store.read(runId).projection.state, "converged"); return store; } function runPath(store, runId, name) { return join(store.paths.pathFor(runId), name); } diff --git a/src/loops/config/config.test.mjs b/src/loops/config/config.test.mjs index 7ec2cf7b..eb38a0a8 100644 --- a/src/loops/config/config.test.mjs +++ b/src/loops/config/config.test.mjs @@ -1,112 +1,79 @@ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; import test from "node:test"; -import { localRecordPath, configRoot, writeLocalRecord } from "./store.mjs"; import { capabilityRevision, readCapabilityCatalog } from "../capabilities/contract.mjs"; -import { validateProfile } from "./profiles.mjs"; const root = resolve(new URL("../../..", import.meta.url).pathname); const cli = join(root, "bin", "burnlist.mjs"); -const policy = { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, maxMilliseconds: 1000 }; +const policy = { id: "repo-verify", argv: [process.execPath, "-e", "process.exit(0)"], + cwd: ".", environment: { inherit: ["PATH"], set: {} }, network: "deny", + filesystem: { read: ["src"], write: [] }, output: { maxBytes: 1024 }, + maxMilliseconds: 1000 }; function fixture() { - const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-config-"))), repo = join(directory, "repo"); - mkdirSync(join(repo, ".burnlist"), { recursive: true }); mkdirSync(join(repo, "src")); execFileSync("git", ["init", "--quiet", repo]); - writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy] })}\n`); - const marker = join(directory, "child-ran"), binary = join(directory, "fake-codex"); - writeFileSync(binary, `#!/bin/sh\necho ran > ${JSON.stringify(marker)}\n`, { mode: 0o700 }); - return { directory, repo, binary, marker, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-config-"))); + const repo = join(directory, "repo"); + mkdirSync(join(repo, ".burnlist"), { recursive: true }); + mkdirSync(join(repo, "src")); + execFileSync("git", ["init", "--quiet", repo]); + writeFileSync(join(repo, ".burnlist", "loop-capabilities.json"), + `${JSON.stringify({ schema: "burnlist-loop-capabilities@1", capabilities: [policy] })}\n`); + return { directory, repo, cleanup: () => rmSync(directory, { recursive: true, force: true }) }; } -function run(context, args) { return spawnSync(process.execPath, [cli, ...args, "--repo", context.repo], { cwd: context.repo, encoding: "utf8" }); } -function profile(context, id, authority) { - const result = run(context, ["agent", "profile", "add", id, "--adapter", "builtin:codex-cli", "--binary", context.binary, "--model", "gpt-5.6-terra", "--effort", "medium", "--authority", authority]); - assert.equal(result.status, 0, result.stderr); return result; + +function run(context, args) { + return spawnSync(process.execPath, [cli, ...args, "--repo", context.repo], + { cwd: context.repo, encoding: "utf8" }); } -function route(context, name, id) { const result = run(context, ["route", "set", name, "--profile", id]); assert.equal(result.status, 0, result.stderr); } + function trust(context) { const grants = join(context.repo, "grants.json"); - writeFileSync(grants, `${JSON.stringify(Object.fromEntries(Object.entries(policy).filter(([key]) => key !== "id")))}\n`); + writeFileSync(grants, `${JSON.stringify(Object.fromEntries( + Object.entries(policy).filter(([key]) => key !== "id")))}\n`); const revision = capabilityRevision(readCapabilityCatalog(context.repo).capabilities[0]); - const result = run(context, ["loop", "capability", "trust", "repo-verify", "--revision", revision, "--grants", grants]); - assert.equal(result.status, 0, result.stderr); + return run(context, ["loop", "capability", "trust", "repo-verify", + "--revision", revision, "--grants", grants]); } -test("M1 setup trusts only private configuration, exact routes, and repo-verify without launching", () => { +test("host-only setup requires capability trust but no agent profiles or routes", () => { const context = fixture(); try { const empty = run(context, ["loop", "setup", "status"]); - assert.equal(empty.status, 1); assert.match(empty.stdout, /MISSING route implementation\.standard/u); assert.match(empty.stdout, /MISSING trust repo-verify/u); - const maker = profile(context, "maker", "write"); - assert.equal(readFileSync(localRecordPath(context.repo, "profiles", "maker"), "utf8"), maker.stdout); - assert.equal(lstatSync(localRecordPath(context.repo, "profiles", "maker")).mode & 0o777, 0o600); - assert.equal(lstatSync(configRoot(context.repo)).mode & 0o777, 0o700); - route(context, "implementation.standard", "maker"); profile(context, "reviewer", "read"); route(context, "review.strong", "reviewer"); trust(context); - const status = run(context, ["loop", "setup", "status"]); - assert.equal(status.status, 0, status.stderr); assert.equal(status.stdout, "Loop setup: ready\n"); - assert.equal(existsSync(context.marker), false); + assert.equal(empty.status, 1); + assert.match(empty.stdout, /MISSING trust repo-verify/u); + assert.doesNotMatch(empty.stdout, /profile|route|adapter/u); + const trusted = trust(context); + assert.equal(trusted.status, 0, trusted.stderr); + const ready = run(context, ["loop", "setup", "status"]); + assert.equal(ready.status, 0, ready.stderr); + assert.equal(ready.stdout, "Loop setup: ready\n"); } finally { context.cleanup(); } }); -test("fresh repositories receive actionable capability and profile guidance", () => { - const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-fresh-"))), repo = join(directory, "repo"); - try { - mkdirSync(repo, { recursive: true }); execFileSync("git", ["init", "--quiet", repo]); - const inspect = spawnSync(process.execPath, [cli, "loop", "capability", "inspect", "repo-verify", "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(inspect.status, 1); assert.match(inspect.stderr, /create \.burnlist\/loop-capabilities\.json/u); - const setup = spawnSync(process.execPath, [cli, "loop", "setup", "status", "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(setup.status, 1); assert.match(setup.stdout, /Review Loop capability example/u); - const invalid = spawnSync(process.execPath, [cli, "agent", "profile", "add", "maker", "--adapter", "builtin:codex-cli", "--binary", process.execPath, "--model", "unknown", "--effort", "fast", "--authority", "write", "--repo", repo], { cwd: repo, encoding: "utf8" }); - assert.equal(invalid.status, 1); assert.match(invalid.stderr, /model must be one of:.*gpt-5\.6-terra/u); - } finally { rmSync(directory, { recursive: true, force: true }); } -}); - -test("M1 setup fails closed for duplicate, malformed, wrong-authority, and untrusted configuration", () => { - for (const scenario of ["duplicate", "wrong-authority", "malformed", "untrusted"]) { - const context = fixture(); - try { - profile(context, "maker", "write"); route(context, "implementation.standard", "maker"); - if (scenario === "duplicate") route(context, "review.strong", "maker"); - else { profile(context, "reviewer", scenario === "wrong-authority" ? "write" : "read"); route(context, "review.strong", "reviewer"); } - if (scenario === "malformed") writeFileSync(localRecordPath(context.repo, "profiles", "reviewer"), "{}\n", { mode: 0o600 }); - if (scenario !== "untrusted") trust(context); - const result = run(context, ["loop", "setup", "status"]); - assert.equal(result.status, 1, scenario); assert.match(result.stdout, /MISSING (routing|profile|trust)/u); assert.equal(existsSync(context.marker), false); - } finally { context.cleanup(); } - } -}); - -test("legacy Docker commands are unsupported and configuration writes fail closed when public", () => { +test("removed managed-agent commands are not Loop configuration surfaces", () => { const context = fixture(); try { - for (const args of [["agent", "controller", "add", "host"], ["agent", "preflight", "maker"]]) { - const result = run(context, args); assert.equal(result.status, 2); assert.match(result.stderr, /Usage: burnlist agent profile add/u); + for (const args of [["agent", "profile", "add", "maker"], ["route", "set", "review.strong"]]) { + const result = run(context, args); + assert.notEqual(result.status, 0); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /builtin:codex-cli/u); } - profile(context, "maker", "write"); chmodSync(configRoot(context.repo), 0o755); - const result = run(context, ["route", "set", "implementation.standard", "--profile", "maker"]); - assert.equal(result.status, 1); assert.match(result.stderr, /unsafe config directory/u); } finally { context.cleanup(); } }); -test("setup read fails closed after config-v1 privacy drifts without mutation", () => { - const context = fixture(); +test("fresh repositories receive actionable capability-only guidance", () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "burnlist-loop-fresh-"))); + const repo = join(directory, "repo"); try { - profile(context, "maker", "write"); route(context, "implementation.standard", "maker"); profile(context, "reviewer", "read"); route(context, "review.strong", "reviewer"); trust(context); - const profilePath = localRecordPath(context.repo, "profiles", "maker"), before = readFileSync(profilePath); - chmodSync(configRoot(context.repo), 0o755); - const result = run(context, ["loop", "setup", "status"]); - assert.equal(result.status, 1); assert.match(result.stdout, /unsafe config directory/u); - assert.deepEqual(readFileSync(profilePath), before); assert.equal(existsSync(context.marker), false); - } finally { context.cleanup(); } -}); - -test("private profile publication is atomic when publication is interrupted", () => { - const context = fixture(); - try { - const value = { schema: "burnlist-loop-agent-profile@1", id: "maker", adapter: "builtin:codex-cli", binary: context.binary, model: "gpt-5.6-terra", effort: "medium", authority: "write" }; - assert.throws(() => writeLocalRecord({ repoRoot: context.repo, collection: "profiles", name: "maker", value, validate: validateProfile, hooks: { beforeRename() { throw new Error("cut"); } } }), /cut/u); - assert.equal(existsSync(localRecordPath(context.repo, "profiles", "maker")), false); - } finally { context.cleanup(); } + mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "--quiet", repo]); + const setup = spawnSync(process.execPath, + [cli, "loop", "setup", "status", "--repo", repo], { cwd: repo, encoding: "utf8" }); + assert.equal(setup.status, 1); + assert.match(setup.stdout, /Review Loop capability example/u); + assert.doesNotMatch(setup.stdout, /profile|route|adapter/u); + } finally { rmSync(directory, { recursive: true, force: true }); } }); diff --git a/src/loops/config/profiles.mjs b/src/loops/config/profiles.mjs deleted file mode 100644 index 32dba252..00000000 --- a/src/loops/config/profiles.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import { isClosedObject, readLocalRecord, writeLocalRecord } from "./store.mjs"; -import { CODEX_MODELS, REASONING_EFFORTS, validateAgentProfile } from "../agents/profile.mjs"; -import { parse } from "node:path"; -import { snapshotTarget } from "../capabilities/snapshot.mjs"; - -const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -const route = /^(?:implementation\.standard|review\.strong)$/u; -const ROUTE_KEYS = ["schema", "route", "profile"]; - -function fail(message) { throw Object.assign(new Error(`Loop profile: ${message}`), { code: "ELOOP_PROFILE" }); } -function profileName(value, label = "profile slug") { if (typeof value !== "string" || !slug.test(value)) fail(`invalid ${label}`); return value; } - -export function validateProfile(value) { - try { return validateAgentProfile(value); } catch (error) { fail(error.message.replace(/^Loop agent: /u, "")); } -} - -export function validateRoute(value) { - if (!isClosedObject(value, ROUTE_KEYS) || value.schema !== "burnlist-loop-route@1" || !route.test(value.route)) fail("route record has invalid schema"); - return { schema: value.schema, route: value.route, profile: profileName(value.profile) }; -} - -export function saveProfile({ repoRoot, slug: name, adapter, binary, model, effort, authority }) { - const value = { schema: "burnlist-loop-agent-profile@1", id: name, adapter, binary, model, effort, authority }; - return writeLocalRecord({ repoRoot, collection: "profiles", name: profileName(name), value, validate: validateProfile }); -} -export function readProfile({ repoRoot, slug: name }) { return readLocalRecord({ repoRoot, collection: "profiles", name: profileName(name), validate: validateProfile }); } -export function saveRoute({ repoRoot, route: name, profile }) { - const value = { schema: "burnlist-loop-route@1", route: name, profile }; - return writeLocalRecord({ repoRoot, collection: "routes", name: String(name).replace(".", "-"), value, validate: validateRoute }); -} -export function readRoute({ repoRoot, route: name }) { if (!route.test(name)) fail("unknown route"); return readLocalRecord({ repoRoot, collection: "routes", name: name.replace(".", "-"), validate: validateRoute }); } - -/** - * No-cost local inspection only: no model invocation, child process, or write. - * Availability is not technical proof and therefore never makes setup ready. - */ -export function doctorProfile({ repoRoot, slug: name }) { - const profile = readProfile({ repoRoot, slug: name }); - try { - const snapshot = snapshotTarget({ root: parse(profile.binary).root, path: profile.binary, maximum: 64 * 1024 * 1024 }); - if ((snapshot.identity.mode & 0o111) === 0) return { available: false, ready: false, profile, reason: "binary is not executable" }; - return { available: true, ready: false, profile, executableDigest: snapshot.digest, reason: "technical guarantees are unavailable; no model probe was run" }; - } catch (error) { return { available: false, ready: false, profile, reason: error?.message || "binary inspection failed" }; } -} - -export const requiredRoutes = Object.freeze([ - { route: "implementation.standard", authority: "write" }, - { route: "review.strong", authority: "read" }, -]); -export { CODEX_MODELS, REASONING_EFFORTS }; diff --git a/src/loops/config/setup.mjs b/src/loops/config/setup.mjs index 69c819ac..91edfc5b 100644 --- a/src/loops/config/setup.mjs +++ b/src/loops/config/setup.mjs @@ -1,28 +1,12 @@ import { assertTrustedCapability } from "../capabilities/trust.mjs"; import { readCapabilityCatalog, resolveCapability } from "../capabilities/contract.mjs"; -import { resolveConfiguredStageOneRoutes } from "../agents/profile.mjs"; -import { readProfile, readRoute, requiredRoutes } from "./profiles.mjs"; -function profileCommand(slug = "", authority = "read|write") { return `burnlist agent profile add ${slug} --adapter builtin:codex-cli --binary --model --effort --authority ${authority}`; } -function routeCommand(route) { return `burnlist route set ${route} --profile `; } function record(kind, id, detail, remedy) { return { kind, id, detail, remedy }; } function clean(error) { return String(error?.message ?? error).replace(/^Loop (?:agent|local config|capability trust|capability): /u, ""); } /** Strictly read-only: configuration and trust readiness only; no child is launched. */ export function setupStatus({ repoRoot } = {}) { - const failures = [], profiles = [], routes = {}; - for (const expected of requiredRoutes) { - let assigned; - try { assigned = readRoute({ repoRoot, route: expected.route }); routes[expected.route] = assigned.profile; } - catch (error) { failures.push(record("route", expected.route, clean(error), routeCommand(expected.route))); continue; } - let profile; - try { profile = readProfile({ repoRoot, slug: assigned.profile }); profiles.push(profile); } - catch (error) { failures.push(record("profile", assigned.profile, clean(error), profileCommand(assigned.profile, expected.authority))); continue; } - } - if (Object.keys(routes).length === requiredRoutes.length && profiles.length === requiredRoutes.length) { - try { resolveConfiguredStageOneRoutes({ profiles, routes }); } - catch (error) { failures.push(record("routing", "stage-one", clean(error), "repair the named profile or route and run burnlist loop setup status again")); } - } + const failures = []; let resolved; try { resolved = resolveCapability(readCapabilityCatalog(repoRoot), "repo-verify"); } catch (error) { failures.push(record("capability", "repo-verify", clean(error), "create .burnlist/loop-capabilities.json from the Review Loop capability example, then run burnlist loop capability inspect repo-verify")); } diff --git a/src/loops/contracts/agent-result.mjs b/src/loops/contracts/agent-result.mjs index 75b4d63b..dae0e276 100644 --- a/src/loops/contracts/agent-result.mjs +++ b/src/loops/contracts/agent-result.mjs @@ -44,7 +44,7 @@ export function createInvocationInput(value) { || value.schema !== "burnlist-loop-invocation-input@1") fail("invalid invocation input"); identity(value, "invocation input"); if (!DIGESTS.item.test(value.itemRevision) || (Object.hasOwn(value, "execution") - && (!["managed", "host"].includes(value.execution) || !["fast", "standard", "strong", "critical"].includes(value.intelligence))) || !DIGESTS.raw.test(value.instructionDigest) + && (value.execution !== "host" || !["fast", "standard", "strong", "critical"].includes(value.intelligence))) || !DIGESTS.raw.test(value.instructionDigest) || !SLUG.test(value.nodeId) || !Array.isArray(value.reviewerEvidence) || value.reviewerEvidence.length > 50 || !sortedUnique(value.reviewerEvidence) || !value.reviewerEvidence.every((ref) => DIGESTS.artifact.test(ref))) fail("invalid invocation evidence"); const instruction = base64(value.instructionBytes, "instruction bytes", 65_536); diff --git a/src/loops/contracts/contracts.test.mjs b/src/loops/contracts/contracts.test.mjs index 48904246..e76cfff5 100644 --- a/src/loops/contracts/contracts.test.mjs +++ b/src/loops/contracts/contracts.test.mjs @@ -27,7 +27,7 @@ function check(outcome, extra = {}) { return { schema: "check-result@1", ...bind function bytes(value) { return Buffer.from(JSON.stringify(value)); } function invocation(extra = {}) { const instruction = Buffer.from("Follow the frozen instructions.\n"); - return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), execution: "managed", intelligence: "strong", instructionDigest: rawSha256(instruction), + return { schema: "burnlist-loop-invocation-input@1", ...binding, itemRevision: d("id1-sha256", "8"), execution: "host", intelligence: "strong", instructionDigest: rawSha256(instruction), instructionBytes: instruction.toString("base64"), candidateContext: Buffer.from("candidate-context@1\n").toString("base64"), reviewerEvidence: [], ...extra }; } function dispatch(input, extra = {}) { diff --git a/src/loops/dsl/__fixtures__/review.ir.json b/src/loops/dsl/__fixtures__/review.ir.json index 00ec4a77..ece912fb 100644 --- a/src/loops/dsl/__fixtures__/review.ir.json +++ b/src/loops/dsl/__fixtures__/review.ir.json @@ -1 +1 @@ -{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"start","budget":{"maxRounds":12,"maxMinutes":60,"maxAgentRuns":18,"maxCheckRuns":8,"maxTransitions":40,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"start","mode":"task","execution":"managed","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"start","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["final-validate","final-review"]},{"kind":"agent","id":"decompose","mode":"task","execution":"managed","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"decompose","independentFrom":null,"requires":[]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"agent","id":"final-review","mode":"review","execution":"managed","intelligence":"critical","role":"reviewer","route":"review.strong","authority":"read","instructions":"final-review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"check","id":"final-validate","capability":"repo-verify"},{"kind":"agent","id":"implement","mode":"task","execution":"managed","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"agent","id":"integrate","mode":"task","execution":"managed","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"integrate","independentFrom":null,"requires":[]},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","execution":"managed","intelligence":"strong","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"validate","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"start","on":"complete","to":"decompose","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"decompose","on":"complete","to":"implement","maxVisits":null},{"from":"final-review","on":"approve","to":"converged","maxVisits":null},{"from":"final-review","on":"reject","to":"decompose","maxVisits":3},{"from":"final-review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"final-validate","on":"pass","to":"final-review","maxVisits":null},{"from":"final-validate","on":"fail","to":"decompose","maxVisits":3},{"from":"implement","on":"complete","to":"validate","maxVisits":null},{"from":"integrate","on":"complete","to":"final-validate","maxVisits":null},{"from":"review","on":"approve","to":"integrate","maxVisits":null},{"from":"review","on":"reject","to":"decompose","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"validate","on":"pass","to":"review","maxVisits":null},{"from":"validate","on":"fail","to":"decompose","maxVisits":3}],"instructions":[{"id":"decompose","digest":"sha256:266f954460a1d833b73ae53b696166acb6b6c49536518f9af86467bf66046791","byteLength":253},{"id":"final-review","digest":"sha256:24c8c2c2574dbbe56732b94e097e35af6e60ea2406feb2ad0019bc28f41b5da8","byteLength":66},{"id":"implement","digest":"sha256:3aea4bc6d773f5b7b1a3e5899bd5be2cbce3aa2313a656e18d6444f244959e93","byteLength":246},{"id":"integrate","digest":"sha256:188626485e23cf2a78105095304fa336f65044d6a7e7559cfa97d804f15be07e","byteLength":236},{"id":"review","digest":"sha256:89a11f423ae87b2c86749cafa2bb956ab7ca7980fafc71aeec408ddc463024aa","byteLength":72},{"id":"start","digest":"sha256:106187b048c9e12f748dac6fe70f3c18a7579a56e9a62be9661bb8ea4e627c43","byteLength":240}]} +{"schema":"burnlist-loop-ir@1","compiler":"burnlist-loop-compiler@1","id":"review","declaredVersion":"0.1.0","entry":"start","budget":{"maxRounds":12,"maxMinutes":60,"maxAgentRuns":18,"maxCheckRuns":8,"maxTransitions":40,"maxOutputBytes":262144},"nodes":[{"kind":"agent","id":"start","mode":"task","execution":"host","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"start","independentFrom":null,"requires":[]},{"kind":"terminal","id":"completed","state":"converged"},{"kind":"gate","id":"converged","gateKind":"convergence","requires":["final-validate","final-review"]},{"kind":"agent","id":"decompose","mode":"task","execution":"host","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"decompose","independentFrom":null,"requires":[]},{"kind":"terminal","id":"exhausted","state":"budget-exhausted"},{"kind":"terminal","id":"failed","state":"failed"},{"kind":"agent","id":"final-review","mode":"review","execution":"host","intelligence":"critical","role":"reviewer","route":"review.strong","authority":"read","instructions":"final-review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"check","id":"final-validate","capability":"repo-verify"},{"kind":"agent","id":"implement","mode":"task","execution":"host","intelligence":"standard","role":"maker","route":"implementation.standard","authority":"write","instructions":"implement","independentFrom":null,"requires":[]},{"kind":"agent","id":"integrate","mode":"task","execution":"host","intelligence":"strong","role":"maker","route":"implementation.standard","authority":"write","instructions":"integrate","independentFrom":null,"requires":[]},{"kind":"terminal","id":"needs-human","state":"needs-human"},{"kind":"agent","id":"review","mode":"review","execution":"host","intelligence":"strong","role":"reviewer","route":"review.strong","authority":"read","instructions":"review","independentFrom":"implement","requires":["fresh-session:enforced","filesystem-write-deny:supervised"]},{"kind":"terminal","id":"stopped","state":"stopped"},{"kind":"check","id":"validate","capability":"repo-verify"}],"failurePolicy":{"error":"failed","timeout":"failed","cancelled":"stopped","lost":"needs-human","exhausted":"exhausted"},"edges":[{"from":"start","on":"complete","to":"decompose","maxVisits":null},{"from":"converged","on":"pass","to":"completed","maxVisits":null},{"from":"converged","on":"fail","to":"needs-human","maxVisits":null},{"from":"decompose","on":"complete","to":"implement","maxVisits":null},{"from":"final-review","on":"approve","to":"converged","maxVisits":null},{"from":"final-review","on":"reject","to":"decompose","maxVisits":3},{"from":"final-review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"final-validate","on":"pass","to":"final-review","maxVisits":null},{"from":"final-validate","on":"fail","to":"decompose","maxVisits":3},{"from":"implement","on":"complete","to":"validate","maxVisits":null},{"from":"integrate","on":"complete","to":"final-validate","maxVisits":null},{"from":"review","on":"approve","to":"integrate","maxVisits":null},{"from":"review","on":"reject","to":"decompose","maxVisits":3},{"from":"review","on":"escalate","to":"needs-human","maxVisits":null},{"from":"validate","on":"pass","to":"review","maxVisits":null},{"from":"validate","on":"fail","to":"decompose","maxVisits":3}],"instructions":[{"id":"decompose","digest":"sha256:266f954460a1d833b73ae53b696166acb6b6c49536518f9af86467bf66046791","byteLength":253},{"id":"final-review","digest":"sha256:24c8c2c2574dbbe56732b94e097e35af6e60ea2406feb2ad0019bc28f41b5da8","byteLength":66},{"id":"implement","digest":"sha256:3aea4bc6d773f5b7b1a3e5899bd5be2cbce3aa2313a656e18d6444f244959e93","byteLength":246},{"id":"integrate","digest":"sha256:188626485e23cf2a78105095304fa336f65044d6a7e7559cfa97d804f15be07e","byteLength":236},{"id":"review","digest":"sha256:89a11f423ae87b2c86749cafa2bb956ab7ca7980fafc71aeec408ddc463024aa","byteLength":72},{"id":"start","digest":"sha256:106187b048c9e12f748dac6fe70f3c18a7579a56e9a62be9661bb8ea4e627c43","byteLength":240}]} diff --git a/src/loops/dsl/__fixtures__/review.revisions.json b/src/loops/dsl/__fixtures__/review.revisions.json index 1eef9d69..e1f8d00e 100644 --- a/src/loops/dsl/__fixtures__/review.revisions.json +++ b/src/loops/dsl/__fixtures__/review.revisions.json @@ -1 +1 @@ -{"source":"ls1-sha256:dc05c650d52c3fe7d48ffb39cf0a9c771ee4d591b09b4e8ba7f83737bf02d087","package":"lp1-sha256:1cad5c04ad4406a80c6faa76531a9c1e6f75d1a7a1b5e7636aeafd2e0ed2a443","executable":"er1-sha256:95d15ba7bc8e46100fc829236049993d6f08b5b32e8d512f04d0cb149a79e3ed"} +{"source":"ls1-sha256:818f2046d350ebf74a946ead5d40aff943b40180c3e2e902d6645659caa47860","package":"lp1-sha256:276630d4e1896178504982fb8489b14def4505a638668da32eb1570d33a47f9e","executable":"er1-sha256:d5ca34001cf1756c13de1dad656a4c147e3a9cc9fe06b45edce43d10dd57c899"} diff --git a/src/loops/dsl/compile.test.mjs b/src/loops/dsl/compile.test.mjs index fabfca88..deced2a8 100644 --- a/src/loops/dsl/compile.test.mjs +++ b/src/loops/dsl/compile.test.mjs @@ -188,7 +188,7 @@ test("closed grammar rejects named later constructs", async () => { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace('', `<${name} id="later"/>`)) }); assert.equal(result.ok, false, name); assert.ok(result.diagnostics.some((item) => item.code === "E_ELEMENT_UNKNOWN")); } - const altered = Buffer.from(source.replace('', '')); + const altered = Buffer.from(source.replace('', '')); const result = compileLoopFiles({ ...files, "review.loop": altered }); assert.equal(result.ok, false); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED")); }); @@ -197,13 +197,13 @@ test("every Stage 1 element required attribute and scalar union is closed", asyn const files = await reviewFiles(), source = files["review.loop"].toString(); const required = [ 'id="start"', 'version="0.1.0"', 'entry="start"', 'max-rounds="12"', 'max-minutes="60"', 'max-agent-runs="18"', 'max-check-runs="8"', 'max-transitions="40"', 'max-output-bytes="262144"', - 'id="implement"', 'mode="task"', 'execution="managed"', 'intelligence="standard"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'id="decompose"', 'id="integrate"', 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', 'id="final-review"', 'independent-from="implement"', 'id="validate"', 'id="final-validate"', 'capability="repo-verify"', 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="start"', 'on="complete"', 'to="decompose"', + 'id="implement"', 'mode="task"', 'execution="host"', 'intelligence="standard"', 'role="maker"', 'route="implementation.standard"', 'authority="write"', 'instructions="implement"', 'id="decompose"', 'id="integrate"', 'id="review"', 'mode="review"', 'role="reviewer"', 'route="review.strong"', 'authority="read"', 'independent-from="implement"', 'requires="fresh-session:enforced,filesystem-write-deny:supervised"', 'id="final-review"', 'independent-from="implement"', 'id="validate"', 'id="final-validate"', 'capability="repo-verify"', 'id="converged"', 'kind="convergence"', 'state="converged"', 'error="failed"', 'timeout="failed"', 'cancelled="stopped"', 'lost="needs-human"', 'exhausted="exhausted"', 'from="start"', 'on="complete"', 'to="decompose"', ]; for (const token of required) { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, "")) }); assert.equal(result.ok, false, token); assert.ok(result.diagnostics.some((item) => item.code === "E_ATTRIBUTE_REQUIRED"), token); } - for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['execution="managed"', 'execution="remote"'], ['intelligence="standard"', 'intelligence="provider-name"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { + for (const [token, replacement] of [['mode="task"', 'mode="stage-two"'], ['execution="host"', 'execution="remote"'], ['intelligence="standard"', 'intelligence="provider-name"'], ['role="maker"', 'role="planner"'], ['authority="write"', 'authority="admin"'], ['route="implementation.standard"', 'route="invalid..route"'], ['kind="convergence"', 'kind="metric"'], ['state="converged"', 'state="done"'], ['max-visits="3"', 'max-visits="0"']]) { const result = compileLoopFiles({ ...files, "review.loop": Buffer.from(source.replace(token, replacement)) }); assert.equal(result.ok, false, replacement); } diff --git a/src/loops/dsl/grammar.mjs b/src/loops/dsl/grammar.mjs index c10ed057..0b01fe6d 100644 --- a/src/loops/dsl/grammar.mjs +++ b/src/loops/dsl/grammar.mjs @@ -61,7 +61,7 @@ export function validateLoop(ast) { if (node.name === "budget") for (const [key, [min, max]] of Object.entries(limits)) value(d, node, key, (v) => positive.test(v) && +v >= min && +v <= max, `an integer from ${min} through ${max}`); if (node.name === "agent") { value(d, node, "mode", (v) => v === "task" || v === "review", "task or review"); - value(d, node, "execution", (v) => v === "managed" || v === "host", "managed or host"); + value(d, node, "execution", (v) => v === "host", "host"); value(d, node, "intelligence", (v) => ["fast", "standard", "strong", "critical"].includes(v), "fast, standard, strong, or critical"); value(d, node, "role", (v) => v === "maker" || v === "reviewer", "maker or reviewer"); value(d, node, "route", (v) => route.test(v), "a Route"); value(d, node, "authority", (v) => v === "read" || v === "write", "read or write"); value(d, node, "instructions", (v) => slug.test(v), "a lowercase slug"); diff --git a/src/loops/dsl/ir-validate.mjs b/src/loops/dsl/ir-validate.mjs index 22ea18fa..1e2643d3 100644 --- a/src/loops/dsl/ir-validate.mjs +++ b/src/loops/dsl/ir-validate.mjs @@ -20,7 +20,7 @@ function same(left, right) { return JSON.stringify(left) === JSON.stringify(righ function validNode(node) { if (!exact(node, nodeKeys[node?.kind] ?? []) || !boundedSlug(node.id)) return false; if (node.kind === "agent") { - if (!["task", "review"].includes(node.mode) || !["managed", "host"].includes(node.execution) + if (!["task", "review"].includes(node.mode) || node.execution !== "host" || !["fast", "standard", "strong", "critical"].includes(node.intelligence) || !route.test(node.route) || !boundedSlug(node.instructions) || !Array.isArray(node.requires) || node.requires.some((item) => typeof item !== "string" || item.length > 128)) return false; @@ -117,7 +117,7 @@ export function validateReplayIr(ir) { const upgraded = { ...ir, nodes: ir.nodes.map((node) => node?.kind === "agent" - ? { ...node, execution: "managed", intelligence: node.mode === "review" ? "strong" : "standard" } + ? { ...node, execution: "host", intelligence: node.mode === "review" ? "strong" : "standard" } : node), }; return validateClosedIr(upgraded); diff --git a/src/loops/minimal-review-e2e.test.mjs b/src/loops/minimal-review-e2e.test.mjs index 817b5ddb..ca8231c5 100644 --- a/src/loops/minimal-review-e2e.test.mjs +++ b/src/loops/minimal-review-e2e.test.mjs @@ -11,7 +11,6 @@ import { build } from "esbuild"; import { createProductionRunAuthority, fixtureItemRef } from "./run/run-test-fixtures.mjs"; import { readLatestRunForItem } from "./run/read-projection.mjs"; import { runStore } from "./run/run-store.mjs"; -import { createStoredProductionRunRunner } from "./run/binder.mjs"; import { readOvenEvents } from "../events/oven-event-store.mjs"; import { cliJson, cliOk, request, startCli, waitForExit, waitForFile, withDashboard } from "./minimal-review-e2e-fixtures.mjs"; import { checklistFixture } from "../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; @@ -56,12 +55,6 @@ function hostFixture(repo) { cliOk(repo, ["loop", "unassign", fixtureItemRef]); cliOk(repo, ["loop", "assign", fixtureItemRef, "loop:project:host-review"]); } -async function managedCheck(repo, runId, expected) { - const runner = createStoredProductionRunRunner({ repoRoot: repo, store: runStore(repo), runId }); - await runner.step(); await runner.step(); await runner.step(); - assert.equal(runner.replay().execution.nodeId, expected); - runner.pause(); -} async function dashboardRenderer(t) { const output = await mkdtemp(join(process.cwd(), ".m9-checklist-render-")); t.after(() => rm(output, { recursive: true, force: true })); @@ -113,150 +106,6 @@ async function dashboardRenderer(t) { }; } -test("M9 no-network CLI slice exposes interruption, repair, invalidation refetch, UI states, escalation, and completion", { timeout: 60_000 }, async (t) => { - const directory = mkdtempSync(join(tmpdir(), "burnlist-m9-e2e-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const { repo } = createProductionRunAuthority(join(directory, "repo")); - const planPath = join(repo, "notes", "burnlists", "inprogress", "260722-001", "burnlist.md"); - addUnassignedItem(planPath, "DIRECT-01 | Unassigned direct-flow control"); - const render = await dashboardRenderer(t); - - await withDashboard(repo, async (baseUrl) => { - const absent = await liveProjection(baseUrl, planPath); - assert.equal(absent.status, 200); assert.equal(JSON.parse(absent.body).loopRun, null); - const view = cliOk(repo, ["loop", "view", fixtureItemRef]); - assert.match(view, /^MODE: ITEM-PINNED$/mu); assert.match(view, /decompose.*implement.*validate.*review/su); - - const escalation = cliJson(repo, ["loop", "create", fixtureItemRef]).runId, escalationCounter = join(directory, "escalation-counter"); - writeFileSync(escalationCounter, "0"); - const escalated = cliJson(repo, ["loop", "run", escalation], { - BURNLIST_FAKE_COUNTER: escalationCounter, BURNLIST_FAKE_OUTCOMES: "complete,complete,complete,escalate", - }); - assert.equal(escalated.state, "needs-human"); - const escalationInspection = cliJson(repo, ["loop", "inspect", escalation]); - assert.deepEqual(edges(escalationInspection).slice(-1), [{ from: "review", outcome: "escalate", to: "needs-human" }]); - const escalationHttp = await liveProjection(baseUrl, planPath); - assert.equal(escalationHttp.status, 200); const escalationProjection = JSON.parse(escalationHttp.body).loopRun; - assert.deepEqual(escalationProjection, escalationInspection); - const needsHumanUi = render("needs-human", escalationProjection); - assert.match(needsHumanUi.dom, /
    /u); - assert.match(needsHumanUi.dom, /
  • ; } -function CompletedDetail({ item }: { item: CompletedItem }) { +function CompletedDetail({ data, item }: { data: ChecklistProgressData; item: CompletedItem }) { const fields = checklistEventDetailFields(item.detail).filter((field) => field.label !== "Completed" && field.values.length); + const run = data.loopRun?.itemRef.endsWith(`#${item.id}`) ? data.loopRun : null; + const topology = run ? itemTopologyProjection(run) : null; return

    {item.id} · {item.title}

    @@ -145,6 +147,16 @@ function CompletedDetail({ item }: { item: CompletedItem }) {
    Completed
    {fields.map((field) =>
    {field.label === "Detail" ? "Outcome" : field.label}
    {field.values.join(" · ")}
    )}
    + {run && topology ?
    +
    Loop{run.loopId}
    + + node.kind === "terminal" && node.terminalState === "converged") + .map((node) => [node.id, "B"])), + }} title={`Loop symbols for ${item.id}`} /> +
    : null}
    ; } @@ -154,7 +166,7 @@ function DetailColumn({ data, selected }: { data: ChecklistProgressData; selecte
    Item detail{status}
    {!selected ?

    No items

    : selected.status === "active" ? - : } + : }
    ; } diff --git a/scripts/package-paths.json b/scripts/package-paths.json index 65ca90a8..c8dd5a9e 100644 --- a/scripts/package-paths.json +++ b/scripts/package-paths.json @@ -2,7 +2,7 @@ "LICENSE", "README.md", "bin/burnlist.mjs", - "dashboard/dist/assets/index-BrGe2UeD.js", + "dashboard/dist/assets/index-DQ8BmSNA.js", "dashboard/dist/assets/index-BmWMxaDn.css", "dashboard/dist/favicon.svg", "dashboard/dist/index.html", diff --git a/src/loops/completion/completion.test.mjs b/src/loops/completion/completion.test.mjs index 16e29f78..abaa19da 100644 --- a/src/loops/completion/completion.test.mjs +++ b/src/loops/completion/completion.test.mjs @@ -10,6 +10,7 @@ import { createLoopController } from "../run/controller.mjs"; import { validateHostExecutionEnvelope } from "../contracts/host-execution.mjs"; import { createProductionRunAuthority, fixtureItemRef, fixtureRunId } from "../run/run-test-fixtures.mjs"; import { runStore } from "../run/run-store.mjs"; +import { readCompletedRunForItem } from "../run/read-projection.mjs"; import { completeLoopRun } from "./completion.mjs"; function context(t) { @@ -54,6 +55,14 @@ test("completion burns one exact converged assignment, writes one receipt, and r assert.equal(readFileSync(value.planPath, "utf8").includes("- [ ] L29"), false); assert.equal(completedLines(value).length, 1); assert.equal(existsSync(runPath(store, fixtureRunId, "completion-receipt.json")), true); + const completedRun = readCompletedRunForItem({ + repoRoot: value.repo, + itemRef: fixtureItemRef, + completedAt: first.completedAt, + title: "Exercise production authority", + }); + assert.equal(completedRun?.runId, fixtureRunId); + assert.equal(completedRun?.state, "converged"); writeFileSync(value.planPath, `${readFileSync(value.planPath, "utf8").trimEnd()}\n\nUnrelated lifecycle note.\n`); const second = completeLoopRun({ repoRoot: value.repo, runId: fixtureRunId, store }); assert.equal(second.alreadyApplied, true); assert.equal(completedLines(value).length, 1); diff --git a/src/loops/run/read-projection.mjs b/src/loops/run/read-projection.mjs index 2d4a7795..ec339dd9 100644 --- a/src/loops/run/read-projection.mjs +++ b/src/loops/run/read-projection.mjs @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import { readJournal } from "./run-journal.mjs"; import { foldRun } from "./run-fold.mjs"; @@ -10,6 +10,7 @@ import { runStore } from "./run-store.mjs"; import { projectRunActivity } from "../events/activity-projection.mjs"; const MAX_RUNS = 128; +const RECEIPT_KEYS = ["schema", "runId", "itemRef", "assignmentId", "completedAt", "title", "planDigest"]; const fail = (message) => { throw Object.assign(new Error(`Run projection: ${message}`), { code: "ERUN_PROJECTION" }); }; function publicNode(node, routes = []) { @@ -150,3 +151,34 @@ export function readLatestRunForItem({ repoRoot, itemRef, markdown = null, itemI selected.agentRoutes = stored.agentRoutes; return presentRun(selected); } + +/** Resolve a completed ledger item through its retained CLI completion receipt. */ +export function readCompletedRunForItem({ repoRoot, itemRef, completedAt, title }) { + const root = resolve(repoRoot); + const base = join(root, ".local", "burnlist", "loop", "m2"); + if (!existsSync(base)) return null; + const current = currentRunAuthority({ root, base, random: () => Buffer.alloc(8) }).read() + .find((entry) => entry.itemRef === itemRef) ?? null; + if (!current) return null; + const receiptPath = join(base, "runs", Buffer.from(current.runId).toString("hex"), "completion-receipt.json"); + let bytes; + try { + const entry = lstatSync(receiptPath); + if (!entry.isFile() || entry.isSymbolicLink() || entry.size < 2 || entry.size > 8192) fail("completion receipt is corrupt"); + bytes = readFileSync(receiptPath); + } catch (error) { + if (error?.code === "ENOENT") return null; + if (error?.code === "ERUN_PROJECTION") throw error; + fail("completion receipt is corrupt"); + } + let receipt; + try { receipt = JSON.parse(bytes); } catch { fail("completion receipt is corrupt"); } + if (!receipt || Object.keys(receipt).length !== RECEIPT_KEYS.length + || !RECEIPT_KEYS.every((key, index) => Object.keys(receipt)[index] === key) + || receipt.schema !== "burnlist-loop-completion@1" + || receipt.runId !== current.runId || receipt.itemRef !== itemRef + || receipt.assignmentId !== current.assignmentId + || receipt.completedAt !== completedAt || receipt.title !== title + || !Buffer.from(`${JSON.stringify(receipt)}\n`).equals(bytes)) fail("completion receipt does not match the completed item"); + return readLatestRunForItem({ repoRoot: root, itemRef }); +} diff --git a/src/loops/run/run-test-fixtures.mjs b/src/loops/run/run-test-fixtures.mjs index 2e8f8d49..19a6aae4 100644 --- a/src/loops/run/run-test-fixtures.mjs +++ b/src/loops/run/run-test-fixtures.mjs @@ -58,7 +58,7 @@ export async function runM4ProgressFixture({ const baseClock = clock ?? (() => at++); const store = runStore(repoRoot, { clock: baseClock }); store.createRun({ runId, itemRef, graph: frozenGraph }); - const runner = createRunRunner({ store, runId, invoke: async ({ nodeId }) => { + const invoke = async ({ nodeId }) => { const outcome = source.shift(); if (!outcome) throw new Error(`run fixture: missing outcome for ${runId}`); const node = frozenGraph.nodes.find((item) => item.id === nodeId); @@ -66,7 +66,18 @@ export async function runM4ProgressFixture({ : node?.kind === "check" ? ["pass", "fail"] : []; if (!permitted.includes(outcome)) throw new Error(`run fixture: ${outcome} is not valid for ${nodeId}`); return { kind: outcome, summary: outcome, outputBytes: 1 }; - }}); + }; + const runner = createRunRunner({ store, runId, invoke, executePreparedAgent: async ({ lease, node }) => { + const current = store.replay(runId), attempt = current.execution.attempt + 1; + store.append(runId, lease, "node-started", { nodeId: node.id, attempt }); + const invocationId = `${String(attempt).padStart(2, "0")}${"0".repeat(30)}`; + store.append(runId, lease, "invocation-started", { nodeId: node.id, attempt, invocationId }); + const result = await invoke({ nodeId: node.id }); + store.append(runId, lease, "invocation-result", { + invocationId, ...result, candidateId: store.replay(runId).execution.candidate?.id ?? null, + }); + return { released: false }; + } }); const snapshots = []; let previous = null; const capture = () => { diff --git a/src/loops/run/runner.mjs b/src/loops/run/runner.mjs index 33ee29a2..21b8a63e 100644 --- a/src/loops/run/runner.mjs +++ b/src/loops/run/runner.mjs @@ -33,7 +33,7 @@ export function createRunRunner({ store, runId, invoke, bindCandidate = null, ex if (completed?.released) lease = null; return { kind: "prepared-agent" }; } - if (node.kind === "agent") return { kind: "awaiting-host" }; + if (node.kind === "agent" && node.execution === "host") return { kind: "awaiting-host" }; return append("node-started", { nodeId: node.id, attempt: execution.attempt + 1 }); } if (node.kind === "terminal") return transition(node.state, "graph"); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 2811d462..0a456283 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -36,7 +36,7 @@ import { starterOvenSource } from "../ovens/oven-starter.mjs"; import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; -import { presentGraph, readLatestRunForItem } from "../loops/run/read-projection.mjs"; +import { presentGraph, readCompletedRunForItem, readLatestRunForItem } from "../loops/run/read-projection.mjs"; import { assignmentStore } from "../loops/assignment/store.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { createOvenJsonSnapshotStore, OVEN_JSON_CACHE_MAX_BYTES } from "./oven-json-snapshot.mjs"; @@ -1081,18 +1081,32 @@ function payloadForPlan(selection, selectedItemId = null) { function loopProjectionForPlan(selection, requestedItemId = null) { const plan = parsePlan(selection.planPath, maxPlanBytes); - const currentItem = requestedItemId + const activeItem = requestedItemId ? plan.items.find((item) => item.id === requestedItemId) : plan.items.find((item) => loopAssignmentForItem(plan.markdown, item.id)); - if (requestedItemId && !currentItem) throw Object.assign(new Error("Loop item is not active in the selected Burnlist"), { code: "EITEM" }); - const assignment = currentItem ? loopAssignmentForItem(plan.markdown, currentItem.id) : null; - if (!currentItem || !assignment) return null; - return readLatestRunForItem({ + if (activeItem) { + const assignment = loopAssignmentForItem(plan.markdown, activeItem.id); + if (!assignment) return null; + return readLatestRunForItem({ + repoRoot: selection.repoRoot, + itemRef: `item:${burnlistIdForPlan(selection.planPath)}#${activeItem.id}`, + markdown: plan.markdown, + itemId: activeItem.id, + assignmentId: assignment["Assignment-Id"], + }); + } + const completedItem = requestedItemId + ? plan.completed.find((item) => item.id === requestedItemId) + : plan.completed.at(-1); + if (!completedItem) { + if (requestedItemId) throw Object.assign(new Error("Loop item is not present in the selected Burnlist"), { code: "EITEM" }); + return null; + } + return readCompletedRunForItem({ repoRoot: selection.repoRoot, - itemRef: `item:${burnlistIdForPlan(selection.planPath)}#${currentItem.id}`, - markdown: plan.markdown, - itemId: currentItem.id, - assignmentId: assignment["Assignment-Id"], + itemRef: `item:${burnlistIdForPlan(selection.planPath)}#${completedItem.id}`, + completedAt: completedItem.completedAt, + title: completedItem.title, }); } diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs index c56aab91..aa23ebf8 100644 --- a/src/server/run-routes.test.mjs +++ b/src/server/run-routes.test.mjs @@ -58,7 +58,7 @@ test("selected progress remains independent from the sanitized read-only Loop pr assert.equal(JSON.parse(scoped.body).loopRun.itemRef, fixtureItemRef); const unknown = await httpRequest(baseUrl, `/api/loop-projection?plan=${encodeURIComponent(planPath)}&item=NOT-ACTIVE`, { method: "GET" }); assert.equal(unknown.status, 404); - assert.match(JSON.parse(unknown.body).error, /not active/u); + assert.match(JSON.parse(unknown.body).error, /not present/u); assert.deepEqual(left.latestResult, { kind: "approve", summary: "approve" }); for (const result of [left.latestMaker, left.latestCheck, left.latestReviewer]) { assert.equal(typeof result?.summary, "string"); From adbb58d5299d6698217a95fc9dc4d56fc7507a35 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 17:32:09 +0200 Subject: [PATCH 15/23] feat: streamline host loop execution --- bin/burnlist.mjs | 2 +- skills/burnlist/SKILL.md | 8 ++++++- skills/burnlist/references/host-execution.md | 24 ++++++++++++++++--- src/cli/commands-help.test.mjs | 4 ++-- src/cli/loop-cli.mjs | 25 ++++++++++++++++---- src/cli/loop-config-cli.mjs | 2 +- src/cli/loop-runtime-cli.test.mjs | 14 +++++++++++ src/loops/config/setup.mjs | 15 +++++++++++- 8 files changed, 81 insertions(+), 13 deletions(-) diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 753b542a..a9872125 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -113,7 +113,7 @@ Usage: burnlist loop view [--repo ] burnlist loop create [--repo ] burnlist loop next|claim [--repo ] - burnlist loop report --result [--repo ] + burnlist loop report (--outcome | --result ) [--repo ] burnlist loop abandon --reason [--repo ] burnlist loop list [--repo ] burnlist loop status|inspect [--repo ] diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index 24491579..eee3e59e 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -199,7 +199,13 @@ Run `burnlist loop setup status` before `loop create`. Paste the complete `burnlist loop view item:#` ASCII output into the task handoff or review request: it records the frozen graph, retries, completion path, and pins. For each agent node use `loop claim`, invoke the selected -provider through the host, and submit `loop report`. Reporting automatically +provider through the host, and submit `loop report`. The successful fast path +is `loop report --outcome complete` for a task or `--outcome approve` +for an independent review; detailed findings and telemetry use `--result`. +Claims are provider-neutral: if a provider hits quota before changing the +workspace and its process has exited, retry the same claim with another ready +provider. Do not write provider wrappers into the candidate repository. +Reporting automatically advances Burnlist-owned checks and gates to the next agent or terminal node. Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`, use proof-gated `loop reconcile` only for a demonstrably lost host claim, and diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md index c97df94b..ce4136d6 100644 --- a/skills/burnlist/references/host-execution.md +++ b/skills/burnlist/references/host-execution.md @@ -40,7 +40,21 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. a graph edge, declare a destination, or run a deterministic check; Burnlist owns all three. -3. Produce one canonical `burnlist-loop-host-report@1` whose `agent-result@1` +3. For the common successful path, inspect the provider result and report the + legal outcome directly. Burnlist copies the sealed identity tuple from the + live claim, so the host does not hand-author authority-bearing JSON: + + ```sh + burnlist loop report cl1-sha256: --outcome complete + burnlist loop report cl1-sha256: --outcome approve + ``` + + Use `complete` only after successful task execution and `approve` only after + an independent read-only review. The command still fails closed on a stale + claim, wrong node mode, or candidate drift. + +4. Findings, rejection, escalation, or telemetry require one canonical + `burnlist-loop-host-report@1` whose `agent-result@1` is bound to that same tuple, with only a legal node-mode outcome. Copy the identity values exactly from `execution`; do not use these placeholders as invented values. A task accepts `complete`; review accepts `approve`, @@ -66,7 +80,7 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. Optional telemetry always uses `burnlist-loop-host-telemetry@1` with `provenance: "host-reported"`; unknown values remain `null`, never guessed. -4. Write the report to a regular, non-symlink file no larger than 256 KiB and +5. Write that detailed report to a regular, non-symlink file no larger than 256 KiB and submit it by the claim id: ```sh @@ -77,7 +91,11 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first. claim, workspace/candidate drift, illegal outcome, or identity mismatch fails closed. Inspect the Run again rather than editing a rejected report. -5. If the host cannot finish, do not report a made-up result. Resolve its live +6. If one provider is unavailable before it mutates the workspace, keep the + provider-neutral claim and retry through another ready provider after the + first process has definitely exited. Do not abandon merely because a quota + was exhausted. If the host cannot finish or process cleanup is uncertain, + do not report a made-up result. Resolve its live claim once: ```sh diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index ab6bda9f..64d4f14b 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -48,7 +48,7 @@ test("top-level and Oven help expose the validated use and set flow", () => { assert.match(top.stdout, /burnlist loop view \[--repo \]/u); assert.match(top.stdout, /burnlist loop create \[--repo \]/u); assert.match(top.stdout, /burnlist loop next\|claim \[--repo \]/u); - assert.match(top.stdout, /burnlist loop report --result \[--repo \]/u); + assert.match(top.stdout, /burnlist loop report \(--outcome \| --result \) \[--repo \]/u); assert.match(top.stdout, /burnlist loop abandon --reason \[--repo \]/u); assert.match(top.stdout, /burnlist loop list \[--repo \]/u); assert.doesNotMatch(top.stdout, /burnlist loop run\|resume/u); @@ -90,7 +90,7 @@ test("nested Loop help snapshots every Stage 1 control", () => { try { const result = run(context.directory, ["loop", "--help"]); assert.equal(result.status, 0, result.stderr); - for (const command of ["create", "next|claim", "report --result ", "abandon --reason ", "list", "pause|stop|complete", "status|inspect", "reconcile"]) { + for (const command of ["create", "next|claim", "report \\(--outcome \\| --result \\)", "abandon --reason ", "list", "pause|stop|complete", "status|inspect", "reconcile"]) { assert.match(result.stdout, new RegExp(`burnlist loop ${command}`, "u")); } assert.doesNotMatch(result.stdout, /loop run|resume/u); diff --git a/src/cli/loop-cli.mjs b/src/cli/loop-cli.mjs index f0843d1a..de0c490d 100644 --- a/src/cli/loop-cli.mjs +++ b/src/cli/loop-cli.mjs @@ -9,11 +9,12 @@ import { runStore } from "../loops/run/run-store.mjs"; import { createLoopController } from "../loops/run/controller.mjs"; import { createProductionRun, createStoredSystemRunRunner } from "../loops/run/binder.mjs"; import { completeLoopRun } from "../loops/completion/completion.mjs"; +import { validateHostExecutionEnvelope } from "../loops/contracts/host-execution.mjs"; function usageText() { return loopConfigUsage(); } function usageError(message = usageText()) { return Object.assign(new Error(message), { exitCode: 2 }); } function options(tokens) { - const positionals = []; let repo = null, recoveryProof = null, resultFile = null, reason = null; + const positionals = []; let repo = null, recoveryProof = null, resultFile = null, reason = null, outcome = null; for (let index = 0; index < tokens.length; index += 1) { if (tokens[index] === "--repo") { if (repo !== null) throw usageError("--repo must be specified at most once."); @@ -32,16 +33,30 @@ function options(tokens) { if (reason !== null) throw usageError("--reason must be specified at most once."); reason = tokens[++index]; if (!reason || reason.startsWith("--")) throw usageError("--reason requires host-cancelled, host-lost, or expired."); } + else if (tokens[index] === "--outcome") { + if (outcome !== null) throw usageError("--outcome must be specified at most once."); + outcome = tokens[++index]; + if (!["complete", "approve"].includes(outcome ?? "")) throw usageError("--outcome requires complete or approve."); + } else if (tokens[index].startsWith("--")) throw usageError(`Unknown option: ${tokens[index]}`); else positionals.push(tokens[index]); } - return { positionals, recoveryProof, resultFile, reason, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; + return { positionals, recoveryProof, resultFile, reason, outcome, repo: repo ? resolve(process.cwd(), repo) : resolveUmbrella(process.cwd()) }; } function validateVerbOptions(verb, opts) { if (opts.recoveryProof && verb !== "reconcile") throw usageError(); - if (opts.resultFile && verb !== "report" || verb === "report" && !opts.resultFile) throw usageError(); + if ((opts.resultFile || opts.outcome) && verb !== "report" + || verb === "report" && Boolean(opts.resultFile) === Boolean(opts.outcome)) throw usageError(); if (opts.reason && verb !== "abandon" || verb === "abandon" && !opts.reason) throw usageError(); } +function simpleReport(envelopeBytes, outcome) { + const execution = validateHostExecutionEnvelope(envelopeBytes).value; + const result = Object.fromEntries(["runId", "nodeId", "attempt", "claimId", "assignmentId", + "invocationId", "recipeRevision", "policyRevision", "inputCandidate"].map((key) => [key, execution[key]])); + return Buffer.from(`${JSON.stringify({ schema: "burnlist-loop-host-report@1", result: { + schema: "agent-result@1", ...result, outcome, findings: [], resolvedFindingIds: [], + }, telemetry: null })}\n`); +} function resultBytes(path) { let fd; try { @@ -98,7 +113,9 @@ export async function runLoopCli(tokens, { runReader, runnerFor, stdout = proces : verb === "inspect" ? controller.inspect(opts.positionals[0]) : verb === "next" ? controller.inspect(opts.positionals[0]) : verb === "claim" ? publicClaim(controller.claim(opts.positionals[0])) - : verb === "report" ? await controller.report(opts.positionals[0], resultBytes(opts.resultFile)) + : verb === "report" ? await controller.report(opts.positionals[0], + opts.resultFile ? resultBytes(opts.resultFile) : simpleReport( + controller.readClaim(store.resolveClaimRef(opts.positionals[0]))?.envelope, opts.outcome)) : verb === "abandon" ? (() => { const runId = store.resolveClaimRef(opts.positionals[0]); if (store.read(runId).execution.terminal) { const error = new Error("ClaimRef is stale"); error.exitCode = 1; throw error; } diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs index 6ce5e8e1..9dc2bec9 100644 --- a/src/cli/loop-config-cli.mjs +++ b/src/cli/loop-config-cli.mjs @@ -6,7 +6,7 @@ import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs"; import { rawSha256 } from "../loops/dsl/hash.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; -const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report --result [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop pause|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; +const loopUsage = "Usage: burnlist loop assign [--repo ] | burnlist loop unassign [--repo ] | burnlist loop view [--repo ]\n burnlist loop create [--repo ]\n burnlist loop next|claim [--repo ]\n burnlist loop report (--outcome | --result ) [--repo ]\n burnlist loop abandon --reason [--repo ]\n burnlist loop pause|stop|complete [--repo ]\n burnlist loop list [--repo ] | burnlist loop status|inspect [--repo ]\n burnlist loop reconcile --recovery-proof [--repo ]\n burnlist loop capability inspect [--repo ]\n burnlist loop capability trust --revision cp1-sha256: --grants [--repo ]\n burnlist loop setup status [--repo ]"; function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); } function parse(tokens, allowed = []) { diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs index ae109d3d..ea7448a1 100644 --- a/src/cli/loop-runtime-cli.test.mjs +++ b/src/cli/loop-runtime-cli.test.mjs @@ -73,6 +73,20 @@ test("claim and report advance checks and gates without launching a provider", ( assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true); }); +test("simple successful outcomes do not require a hand-authored report", (t) => { + const directory = mkdtempSync(join(tmpdir(), "host-simple-report-cli-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { repo } = createProductionRunAuthority(join(directory, "repo")); + const runId = created(repo); + for (let attempts = 0; attempts < 12; attempts += 1) { + if (JSON.parse(command(repo, ["status", runId])).state === "converged") break; + const execution = JSON.parse(command(repo, ["claim", runId])).execution; + command(repo, ["report", execution.claimId, "--outcome", + ["review", "final-review"].includes(execution.nodeId) ? "approve" : "complete"]); + } + assert.equal(JSON.parse(command(repo, ["status", runId])).state, "converged"); +}); + test("a claimed provider cannot be stolen and reviewer candidate drift rejects its report", (t) => { const directory = mkdtempSync(join(tmpdir(), "host-claim-loop-cli-")); t.after(() => rmSync(directory, { recursive: true, force: true })); diff --git a/src/loops/config/setup.mjs b/src/loops/config/setup.mjs index 91edfc5b..fd9994ec 100644 --- a/src/loops/config/setup.mjs +++ b/src/loops/config/setup.mjs @@ -1,5 +1,7 @@ import { assertTrustedCapability } from "../capabilities/trust.mjs"; import { readCapabilityCatalog, resolveCapability } from "../capabilities/contract.mjs"; +import { lstatSync } from "node:fs"; +import { resolve } from "node:path"; function record(kind, id, detail, remedy) { return { kind, id, detail, remedy }; } function clean(error) { return String(error?.message ?? error).replace(/^Loop (?:agent|local config|capability trust|capability): /u, ""); } @@ -11,7 +13,18 @@ export function setupStatus({ repoRoot } = {}) { try { resolved = resolveCapability(readCapabilityCatalog(repoRoot), "repo-verify"); } catch (error) { failures.push(record("capability", "repo-verify", clean(error), "create .burnlist/loop-capabilities.json from the Review Loop capability example, then run burnlist loop capability inspect repo-verify")); } if (resolved) { - try { assertTrustedCapability({ repoRoot, resolved }); } + try { + const trusted = assertTrustedCapability({ repoRoot, resolved }); + const paths = new Set([trusted.grants.cwd, ...trusted.grants.filesystem.read, ...trusted.grants.filesystem.write]); + for (const path of paths) { + try { lstatSync(resolve(repoRoot, path)); } + catch (error) { + if (error?.code !== "ENOENT") throw error; + failures.push(record("path", path, "configured capability path does not exist", + "create the path or narrow the capability catalog and grants to existing paths, then trust the new revision")); + } + } + } catch (error) { failures.push(record("trust", "repo-verify", clean(error), `burnlist loop capability trust repo-verify --revision ${resolved.revision} --grants `)); } } return { ready: failures.length === 0, failures }; From 83d256934bdbce7fc484f3a2b2248723ba6007fe Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 17:39:12 +0200 Subject: [PATCH 16/23] fix: compact serial loop feedback rails --- .../components/LoopGraph/LoopGraph.test.ts | 3 ++- .../components/LoopGraph/compact-layout.ts | 24 ++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts index fa80e257..6152e3e7 100644 --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts @@ -131,5 +131,6 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt assert.match(compact, /S[\s\S]*D[\s\S]*I[\s\S]*R[\s\S]*F[\s\S]*G[\s\S]*B/u); assert.match(compact, /reject/u); const drawing = compact.replace(/<[^>]+>/gu, ""); - assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) < 70); + assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) < 24); + assert.doesNotMatch(drawing, /┬─┬/u); }); diff --git a/dashboard/src/components/LoopGraph/compact-layout.ts b/dashboard/src/components/LoopGraph/compact-layout.ts index 98a9dc3b..a75c7a69 100644 --- a/dashboard/src/components/LoopGraph/compact-layout.ts +++ b/dashboard/src/components/LoopGraph/compact-layout.ts @@ -50,18 +50,15 @@ function serialCompact(run: LoopGraphProjection, path: string[], options: Compac const symbols = loopSymbols(run.graph.nodes, options.symbols); const loopbacks = alternate.filter((edge) => pathIds.has(edge.from) && pathIds.has(edge.to) && path.indexOf(edge.to) < path.indexOf(edge.from)); - const width = Math.max( - 28, - ...path.map((id) => symbols.get(id)!.length + 2), - ...run.graph.edges.map((edge) => edge.on.length + 5), - ); + const feedbackLabelWidth = Math.max(0, ...loopbacks.map((edge) => edge.on.length)); + const railX = Math.max(10, feedbackLabelWidth + 5); + const width = Math.max(14, railX + 2, ...path.map((id) => symbols.get(id)!.length + 2)); const graph = drawing(path.length * 2 + detours.length * 3, width); const positions = new Map(); path.forEach((id, index) => { const y = index * 2; positions.set(id, { x: 0, y }); - graph.text(0, y, symbols.get(id)!); if (index + 1 < path.length) { const edge = run.graph.edges.find((candidate) => candidate.from === id && candidate.to === path[index + 1])!; graph.put(0, y + 1, "▼"); @@ -90,15 +87,20 @@ function serialCompact(run: LoopGraphProjection, path: string[], options: Compac graph.put(2, to.y, "◀"); if (options.showLabels) graph.text(from.x + 2, from.y, edge.on); }); - loopbacks.forEach((edge, index) => { + if (loopbacks.length) { + const top = Math.min(...loopbacks.map((edge) => positions.get(edge.to)!.y)); + const bottom = Math.max(...loopbacks.map((edge) => positions.get(edge.from)!.y)); + graph.vertical(railX, top, bottom); + } + loopbacks.forEach((edge) => { const from = positions.get(edge.from)!, to = positions.get(edge.to)!; - const rail = width - 2 - index * 2; - graph.horizontal(2, rail, from.y); - graph.vertical(rail, to.y, from.y); - graph.horizontal(2, rail, to.y); + graph.horizontal(2, railX, from.y); + graph.horizontal(2, railX, to.y); graph.put(2, to.y, "◀"); if (options.showLabels) graph.text(4, from.y, edge.on); }); + for (const [id, position] of positions) + graph.text(position.x, position.y, symbols.get(id)!); return { lines: graph.cells.map((line) => line.join("").trimEnd()), positions, From cedb6294f5e4cb825f682b44d843d40f928bbe95 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 25 Jul 2026 18:12:21 +0200 Subject: [PATCH 17/23] feat: add responsive layered loop layout --- .../src/components/LoopGraph/LoopCompact.tsx | 20 ++- .../components/LoopGraph/LoopGraph.test.ts | 12 +- .../components/LoopGraph/compact-layout.ts | 5 +- .../LoopGraph/serial-compact-layout.ts | 118 ++++++++++++++++++ skills/burnlist/SKILL.md | 4 + 5 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 dashboard/src/components/LoopGraph/serial-compact-layout.ts diff --git a/dashboard/src/components/LoopGraph/LoopCompact.tsx b/dashboard/src/components/LoopGraph/LoopCompact.tsx index e693e614..ffdfe754 100644 --- a/dashboard/src/components/LoopGraph/LoopCompact.tsx +++ b/dashboard/src/components/LoopGraph/LoopCompact.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import type { LoopGraphProjection } from "./LoopGraph"; import { layoutCompactLoop } from "./compact-layout"; import "./LoopGraph.css"; @@ -39,6 +40,19 @@ export function itemTopologyProjection(run: LoopGraphProjection): LoopGraphProje export function LoopCompact({ run, title = "Compact Loop topology", labels = "hidden", symbols, variant = "topology", }: LoopCompactProps) { + const host = useRef(null); + const [characters, setCharacters] = useState(72); + useEffect(() => { + if (!host.current || typeof ResizeObserver === "undefined") return; + const style = getComputedStyle(host.current); + const context = document.createElement("canvas").getContext("2d"); + if (context) context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const characterWidth = Math.max(1, (context?.measureText("0000000000").width ?? 90) / 10); + const observer = new ResizeObserver(([entry]) => + setCharacters(Math.max(24, Math.floor(entry.contentRect.width / characterWidth) - 1))); + observer.observe(host.current); + return () => observer.disconnect(); + }, []); if (!run) return null; const topologyRun = variant === "topology" ? itemTopologyProjection(run) : null; const displayRun = variant === "burn-cycle" ? { @@ -69,13 +83,15 @@ export function LoopCompact({ ...Object.fromEntries(run.graph.nodes.filter((node) => node.kind === "terminal" && node.terminalState === "converged").map((node) => [node.id, "B"])), ...symbols, }; - const layout = layoutCompactLoop(displayRun, { showLabels: labels === "outcomes", symbols: displaySymbols }); + const layout = layoutCompactLoop(displayRun, { + availableCharacters: characters, showLabels: labels === "outcomes", symbols: displaySymbols, + }); const current = layout.positions.get(displayRun.currentNode); const drawing = layout.lines.join("\n"); const offset = current ? layout.lines.slice(0, current.y).reduce((total, line) => total + line.length + 1, 0) + current.x : -1; - return
    +  return 
         {offset >= 0
           ? <>{drawing.slice(0, offset)}{drawing[offset]}{drawing.slice(offset + 1)}
           : drawing}
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index 6152e3e7..f6d2c23f 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -3,6 +3,7 @@ import test from "node:test";
     import { createElement } from "react";
     import { renderToStaticMarkup } from "react-dom/server";
     import { LoopCompact } from "./LoopCompact";
    +import { layoutCompactLoop } from "./compact-layout";
     import { LoopGraph, type LoopGraphProjection } from "./LoopGraph";
     import { LoopLegend } from "./LoopLegend";
     
    @@ -128,9 +129,16 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt
         },
       });
       const compact = renderToStaticMarkup(createElement(LoopCompact, { run, labels: "outcomes" }));
    -  assert.match(compact, /S[\s\S]*D[\s\S]*I[\s\S]*R[\s\S]*F[\s\S]*G[\s\S]*B/u);
    +  for (const symbol of ["S", "D", "I1", "V", "R", "I2", "F1", "F2", "G", "B"])
    +    assert.match(compact, new RegExp(symbol, "u"));
       assert.match(compact, /reject/u);
       const drawing = compact.replace(/<[^>]+>/gu, "");
    -  assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) < 24);
    +  assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) <= 72);
    +  assert.ok(drawing.split("\n").length < 14);
       assert.doesNotMatch(drawing, /┬─┬/u);
    +  const wide = layoutCompactLoop(run, { availableCharacters: 100, showLabels: true });
    +  const narrow = layoutCompactLoop(run, { availableCharacters: 48, showLabels: true });
    +  assert.ok(Math.max(...wide.lines.map((line) => line.length)) <= 100);
    +  assert.ok(Math.max(...narrow.lines.map((line) => line.length)) <= 48);
    +  assert.ok(narrow.lines.length > wide.lines.length);
     });
    diff --git a/dashboard/src/components/LoopGraph/compact-layout.ts b/dashboard/src/components/LoopGraph/compact-layout.ts
    index a75c7a69..f12ecff9 100644
    --- a/dashboard/src/components/LoopGraph/compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/compact-layout.ts
    @@ -1,5 +1,6 @@
     import type { LoopGraphProjection } from "./LoopGraph";
     import { loopPrimaryPath, loopSymbols } from "./loop-symbols";
    +import { layoutSerialCompact } from "./serial-compact-layout";
     
     type Position = { x: number; y: number };
     
    @@ -36,9 +37,11 @@ function drawing(rows: number, columns: number) {
       return { cells, put, text, horizontal, vertical };
     }
     
    -type CompactOptions = { showLabels?: boolean; symbols?: Record };
    +type CompactOptions = { availableCharacters?: number; showLabels?: boolean; symbols?: Record };
     
     function serialCompact(run: LoopGraphProjection, path: string[], options: CompactOptions) {
    +  const layered = layoutSerialCompact(run, path, options);
    +  if (layered) return layered;
       if (path.length < 6) return null;
       const pathIds = new Set(path);
       const primary = new Set(path.slice(0, -1).map((from, index) => `${from}\0${path[index + 1]}`));
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    new file mode 100644
    index 00000000..291b0fff
    --- /dev/null
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -0,0 +1,118 @@
    +import type { LoopGraphProjection } from "./LoopGraph";
    +import { loopSymbols } from "./loop-symbols";
    +
    +type Position = { x: number; y: number };
    +type Options = { availableCharacters?: number; showLabels?: boolean; symbols?: Record };
    +
    +const glyphs: Record = {
    +  1: "│", 2: "─", 3: "└", 4: "│", 5: "│", 6: "┌", 7: "├",
    +  8: "─", 9: "┘", 10: "─", 11: "┴", 12: "┐", 13: "┤", 14: "┬", 15: "┼",
    +};
    +
    +function canvas(rows: number, columns: number) {
    +  const cells = Array.from({ length: rows }, () => Array(columns).fill(" "));
    +  const masks = Array.from({ length: rows }, () => Array(columns).fill(0));
    +  const put = (x: number, y: number, value: string) => {
    +    if (x < 0 || x >= columns || y < 0 || y >= rows) return;
    +    cells[y][x] = value; masks[y][x] = 0;
    +  };
    +  const connect = (x: number, y: number, mask: number) => {
    +    if (x < 0 || x >= columns || y < 0 || y >= rows) return;
    +    masks[y][x] |= mask; cells[y][x] = glyphs[masks[y][x]];
    +  };
    +  const horizontal = (from: number, to: number, y: number) => {
    +    const left = Math.min(from, to), right = Math.max(from, to);
    +    for (let x = left; x <= right; x += 1)
    +      connect(x, y, (x > left ? 8 : 0) | (x < right ? 2 : 0));
    +  };
    +  const vertical = (x: number, from: number, to: number) => {
    +    const top = Math.min(from, to), bottom = Math.max(from, to);
    +    for (let y = top; y <= bottom; y += 1)
    +      connect(x, y, (y > top ? 1 : 0) | (y < bottom ? 4 : 0));
    +  };
    +  const text = (x: number, y: number, value: string) =>
    +    [...value].forEach((character, index) => put(x + index, y, character));
    +  return { cells, put, horizontal, vertical, text };
    +}
    +
    +/**
    + * A small Sugiyama-style specialisation for the common workflow shape:
    + * rank the success spine, wrap it into balanced serpentine rows, then route
    + * backwards edges on destination-grouped orthogonal rails.
    + */
    +export function layoutSerialCompact(
    +  run: LoopGraphProjection, path: string[], options: Options,
    +) {
    +  if (path.length < 6) return null;
    +  const pathIndex = new Map(path.map((id, index) => [id, index]));
    +  const primary = path.slice(0, -1).map((from, index) =>
    +    run.graph.edges.find((edge) => edge.from === from && edge.to === path[index + 1])!);
    +  const primaryKeys = new Set(primary.map((edge) => `${edge.from}\0${edge.to}`));
    +  const feedback = run.graph.edges.filter((edge) =>
    +    !primaryKeys.has(`${edge.from}\0${edge.to}`)
    +    && pathIndex.has(edge.from) && pathIndex.has(edge.to)
    +    && pathIndex.get(edge.to)! < pathIndex.get(edge.from)!);
    +  const other = run.graph.edges.filter((edge) =>
    +    !primaryKeys.has(`${edge.from}\0${edge.to}`) && !feedback.includes(edge));
    +  if (other.some((edge) => pathIndex.has(edge.from) || pathIndex.has(edge.to))) return null;
    +
    +  const symbols = loopSymbols(run.graph.nodes, options.symbols);
    +  const labelWidth = options.showLabels
    +    ? Math.max(0, ...primary.map((edge) => edge.on.length)) : 0;
    +  const slot = Math.max(7, labelWidth + 5, ...path.map((id) => symbols.get(id)!.length + 5));
    +  const available = Math.max(24, options.availableCharacters ?? 72);
    +  const destinations = [...new Set(feedback.map((edge) => edge.to))];
    +  const leftMargin = destinations.length ? destinations.length * 2 + 2 : 0;
    +  const capacity = Math.max(2, Math.floor((available - leftMargin + slot) / slot));
    +  const rowCount = Math.ceil(path.length / capacity);
    +  const perRow = Math.ceil(path.length / rowCount);
    +  const rowHeight = 4 + Math.max(0, ...Array.from({ length: rowCount }, (_, row) =>
    +    feedback.filter((edge) => Math.floor(pathIndex.get(edge.from)! / perRow) === row).length));
    +  const positions = new Map();
    +  path.forEach((id, index) => {
    +    const row = Math.floor(index / perRow), offset = index % perRow;
    +    const count = Math.min(perRow, path.length - row * perRow);
    +    const column = row % 2 ? count - offset - 1 : offset;
    +    positions.set(id, { x: leftMargin + column * slot, y: row * rowHeight });
    +  });
    +  const width = Math.min(available, Math.max(...[...positions.values()].map((point) => point.x)) + slot);
    +  const graph = canvas(rowCount * rowHeight, width);
    +
    +  primary.forEach((edge) => {
    +    const from = positions.get(edge.from)!, to = positions.get(edge.to)!;
    +    if (from.y === to.y) {
    +      const right = from.x < to.x;
    +      graph.horizontal(from.x + (right ? 2 : -2), to.x + (right ? -2 : 2), from.y);
    +      graph.put(to.x + (right ? -2 : 2), to.y, right ? "▶" : "◀");
    +      if (options.showLabels)
    +        graph.text(Math.min(from.x, to.x) + 3, from.y, edge.on);
    +    } else {
    +      graph.vertical(from.x, from.y + 1, to.y - 1);
    +      graph.put(to.x, to.y - 1, "▼");
    +      if (options.showLabels) graph.text(from.x + 2, from.y + 1, edge.on);
    +    }
    +  });
    +
    +  const rails = new Map(destinations.map((id, index) => [id, index * 2]));
    +  const sourceOffsets = new Map();
    +  feedback.forEach((edge) => {
    +    const from = positions.get(edge.from)!, to = positions.get(edge.to)!;
    +    const row = Math.floor(pathIndex.get(edge.from)! / perRow);
    +    const offset = sourceOffsets.get(row) ?? 0;
    +    sourceOffsets.set(row, offset + 1);
    +    const sourceY = from.y + 2 + offset, railX = rails.get(edge.to)!;
    +    graph.vertical(from.x, from.y + 1, sourceY);
    +    graph.horizontal(railX, from.x, sourceY);
    +    graph.vertical(railX, to.y + 1, sourceY);
    +    graph.horizontal(railX, to.x, to.y + 1);
    +    graph.put(to.x, to.y + 1, "▲");
    +    if (options.showLabels) graph.text(Math.min(from.x, width - edge.on.length), sourceY, edge.on);
    +  });
    +
    +  for (const [id, position] of positions) graph.text(position.x, position.y, symbols.get(id)!);
    +  return {
    +    lines: graph.cells.map((line) => line.join("").trimEnd())
    +      .filter((line, index, lines) => line.length || lines.slice(index + 1).some(Boolean)),
    +    positions,
    +  };
    +}
    diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md
    index eee3e59e..46854738 100644
    --- a/skills/burnlist/SKILL.md
    +++ b/skills/burnlist/SKILL.md
    @@ -207,6 +207,10 @@ workspace and its process has exited, retry the same claim with another ready
     provider. Do not write provider wrappers into the candidate repository.
     Reporting automatically
     advances Burnlist-owned checks and gates to the next agent or terminal node.
    +When the user wants to watch a Loop, assign every intended item first and
    +verify each with `loop view`; announce the dashboard as Loop-ready only after
    +the item projections expose those assignments. Repository registration alone
    +is not a Loop-visibility milestone.
     Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`,
     use proof-gated `loop reconcile` only for a demonstrably lost host claim, and
     apply a converged Run through idempotent `loop complete`.
    
    From 28ab6ca62cea2ab28d2d3cd46ed8c8f2bc56f4d7 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 18:20:25 +0200
    Subject: [PATCH 18/23] fix: balance loop layouts across levels
    
    ---
     .../components/LoopGraph/LoopGraph.test.ts    |  2 ++
     .../LoopGraph/serial-compact-layout.ts        | 21 +++++++++++++++++--
     2 files changed, 21 insertions(+), 2 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index f6d2c23f..0388592f 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -137,8 +137,10 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt
       assert.ok(drawing.split("\n").length < 14);
       assert.doesNotMatch(drawing, /┬─┬/u);
       const wide = layoutCompactLoop(run, { availableCharacters: 100, showLabels: true });
    +  const extraWide = layoutCompactLoop(run, { availableCharacters: 180, showLabels: true });
       const narrow = layoutCompactLoop(run, { availableCharacters: 48, showLabels: true });
       assert.ok(Math.max(...wide.lines.map((line) => line.length)) <= 100);
       assert.ok(Math.max(...narrow.lines.map((line) => line.length)) <= 48);
       assert.ok(narrow.lines.length > wide.lines.length);
    +  assert.ok(new Set([...extraWide.positions.values()].map((position) => position.y)).size >= 2);
     });
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    index 291b0fff..bba73852 100644
    --- a/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -63,8 +63,25 @@ export function layoutSerialCompact(
       const available = Math.max(24, options.availableCharacters ?? 72);
       const destinations = [...new Set(feedback.map((edge) => edge.to))];
       const leftMargin = destinations.length ? destinations.length * 2 + 2 : 0;
    -  const capacity = Math.max(2, Math.floor((available - leftMargin + slot) / slot));
    -  const rowCount = Math.ceil(path.length / capacity);
    +  const minimumRows = path.length >= 7 ? 2 : 1;
    +  const candidates = Array.from(
    +    { length: Math.min(5, Math.ceil(path.length / 2)) - minimumRows + 1 },
    +    (_, index) => minimumRows + index,
    +  );
    +  const rowCount = candidates.map((rows) => {
    +    const columns = Math.ceil(path.length / rows);
    +    const estimatedWidth = leftMargin + (columns - 1) * slot + 2;
    +    const estimatedHeight = rows * 5;
    +    const overflow = Math.max(0, estimatedWidth - available);
    +    const aspect = estimatedWidth / Math.max(1, estimatedHeight * 2);
    +    const emptySlots = rows * columns - path.length;
    +    return {
    +      rows,
    +      score: overflow * 1_000
    +        + Math.abs(Math.log(Math.max(.01, aspect) / 2.2)) * 20
    +        + emptySlots * 1.5 + rows * .15,
    +    };
    +  }).sort((left, right) => left.score - right.score || left.rows - right.rows)[0].rows;
       const perRow = Math.ceil(path.length / rowCount);
       const rowHeight = 4 + Math.max(0, ...Array.from({ length: rowCount }, (_, row) =>
         feedback.filter((edge) => Math.floor(pathIndex.get(edge.from)! / perRow) === row).length));
    
    From c8311e6f2c41f494b4d865867c5ca129be35c435 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 18:22:58 +0200
    Subject: [PATCH 19/23] fix: pad loop edge labels
    
    ---
     .../components/LoopGraph/LoopGraph.test.ts    |  4 +++-
     .../LoopGraph/serial-compact-layout.ts        | 22 ++++++++++++++++---
     2 files changed, 22 insertions(+), 4 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index 0388592f..d6b27edc 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -132,9 +132,11 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt
       for (const symbol of ["S", "D", "I1", "V", "R", "I2", "F1", "F2", "G", "B"])
         assert.match(compact, new RegExp(symbol, "u"));
       assert.match(compact, /reject/u);
    +  assert.match(compact, /─ complete ─/u);
    +  assert.match(compact, /─ reject ─/u);
       const drawing = compact.replace(/<[^>]+>/gu, "");
       assert.ok(Math.max(...drawing.split("\n").map((line) => line.length)) <= 72);
    -  assert.ok(drawing.split("\n").length < 14);
    +  assert.ok(drawing.split("\n").length < 20);
       assert.doesNotMatch(drawing, /┬─┬/u);
       const wide = layoutCompactLoop(run, { availableCharacters: 100, showLabels: true });
       const extraWide = layoutCompactLoop(run, { availableCharacters: 180, showLabels: true });
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    index bba73852..ce1451e8 100644
    --- a/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -35,6 +35,21 @@ function canvas(rows: number, columns: number) {
       return { cells, put, horizontal, vertical, text };
     }
     
    +function paddedLabel(value: string) {
    +  return ` ${value} `;
    +}
    +
    +function placeHorizontalLabel(
    +  graph: ReturnType, from: number, to: number, y: number, value: string,
    +) {
    +  const label = paddedLabel(value);
    +  const left = Math.min(from, to), right = Math.max(from, to);
    +  const room = right - left - 3;
    +  if (room < label.length) return false;
    +  graph.text(left + Math.floor((right - left - label.length) / 2), y, label);
    +  return true;
    +}
    +
     /**
      * A small Sugiyama-style specialisation for the common workflow shape:
      * rank the success spine, wrap it into balanced serpentine rows, then route
    @@ -59,7 +74,7 @@ export function layoutSerialCompact(
       const symbols = loopSymbols(run.graph.nodes, options.symbols);
       const labelWidth = options.showLabels
         ? Math.max(0, ...primary.map((edge) => edge.on.length)) : 0;
    -  const slot = Math.max(7, labelWidth + 5, ...path.map((id) => symbols.get(id)!.length + 5));
    +  const slot = Math.max(9, labelWidth + 9, ...path.map((id) => symbols.get(id)!.length + 7));
       const available = Math.max(24, options.availableCharacters ?? 72);
       const destinations = [...new Set(feedback.map((edge) => edge.to))];
       const leftMargin = destinations.length ? destinations.length * 2 + 2 : 0;
    @@ -102,7 +117,7 @@ export function layoutSerialCompact(
           graph.horizontal(from.x + (right ? 2 : -2), to.x + (right ? -2 : 2), from.y);
           graph.put(to.x + (right ? -2 : 2), to.y, right ? "▶" : "◀");
           if (options.showLabels)
    -        graph.text(Math.min(from.x, to.x) + 3, from.y, edge.on);
    +        placeHorizontalLabel(graph, from.x, to.x, from.y, edge.on);
         } else {
           graph.vertical(from.x, from.y + 1, to.y - 1);
           graph.put(to.x, to.y - 1, "▼");
    @@ -123,7 +138,8 @@ export function layoutSerialCompact(
         graph.vertical(railX, to.y + 1, sourceY);
         graph.horizontal(railX, to.x, to.y + 1);
         graph.put(to.x, to.y + 1, "▲");
    -    if (options.showLabels) graph.text(Math.min(from.x, width - edge.on.length), sourceY, edge.on);
    +    if (options.showLabels)
    +      placeHorizontalLabel(graph, railX, from.x, sourceY, edge.on);
       });
     
       for (const [id, position] of positions) graph.text(position.x, position.y, symbols.get(id)!);
    
    From 265bde0240a82c73454d82ddfe2739b05c172ad6 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 18:35:56 +0200
    Subject: [PATCH 20/23] feat: simplify built-in loop catalog
    
    ---
     README.md                                     | 17 ++++-----
     loops/branch/branch.loop                      | 25 +++++++++++++
     loops/branch/instructions.md                  | 23 ++++++++++++
     loops/gate/gate.loop                          | 17 +++++++++
     loops/gate/instructions.md                    |  7 ++++
     loops/review/instructions.md                  | 36 ++++---------------
     loops/review/review.loop                      | 27 ++++----------
     skills/burnlist/SKILL.md                      | 19 ++++++----
     src/cli/commands-help.test.mjs                |  8 ++---
     src/cli/loop-runtime-cli.test.mjs             |  7 ++--
     src/loops/assignment/assignment.mjs           |  6 ++--
     src/loops/assignment/assignment.test.mjs      |  9 +++++
     .../compiler-review/instructions.md           | 33 +++++++++++++++++
     .../__fixtures__/compiler-review/review.loop  | 34 ++++++++++++++++++
     src/loops/dsl/compile.test.mjs                |  4 +--
     src/loops/dsl/grammar.mjs                     |  7 ++--
     src/loops/dsl/ir-validate.mjs                 |  6 ++--
     src/loops/run/binder.mjs                      | 10 +++---
     website/src/content/docs/cli.mdx              | 15 ++++----
     website/src/content/docs/loops.mdx            | 27 ++++++++------
     20 files changed, 234 insertions(+), 103 deletions(-)
     create mode 100644 loops/branch/branch.loop
     create mode 100644 loops/branch/instructions.md
     create mode 100644 loops/gate/gate.loop
     create mode 100644 loops/gate/instructions.md
     create mode 100644 src/loops/dsl/__fixtures__/compiler-review/instructions.md
     create mode 100644 src/loops/dsl/__fixtures__/compiler-review/review.loop
    
    diff --git a/README.md b/README.md
    index 850dbf36..5308bcde 100644
    --- a/README.md
    +++ b/README.md
    @@ -148,15 +148,15 @@ Untracked hook configs are added to `.git/info/exclude` by default; tracked conf
     
     Use `burnlist --help` for dashboard ports, scan roots, local state paths, and Oven data bindings.
     
    -## Review Loop (Stage 1)
    +## Built-in Loops (Stage 1)
     
    -The built-in `review` Loop is an optional, serial foreground workflow for an
    -assigned item:
    +Items may use direct execution or one of exactly three built-in Loops:
     
     ```text
    -maker -> repository check -> fresh reviewer -> convergence gate -> CLI completion
    -  ^                                  |
    -  +------------ check failure / rejection
    +direct  Start -> Implement -> Burn
    +review  Start -> Implement -> Check -> Review -> Burn
    +gate    Start -> Implement -> Check -> Burn
    +branch  Start -> Plan -> N host-orchestrated branches -> Merge -> Check -> Review -> Burn
     ```
     
     Inspect and explicitly trust the repository check with `burnlist loop
    @@ -186,9 +186,10 @@ The Checklist UI is read-only and shows the active node, attempt, results,
     transition history, and paused, error, or terminal state. Burnlist enforces
     the graph, claim identity, trusted checks, budgets, closed outcomes, and atomic
     CLI writes. Provider permissions remain host-supervised, not an OS sandbox.
    -Parallel execution, Docker isolation, metrics gates,
    +Burnlist-native parallel scheduling, Docker isolation, metrics gates,
     forecasting, worktrees, and background execution are deliberately unsupported
    -in Stage 1. Items with no Loop assignment keep the ordinary direct
    +in Stage 1. The Branch host uses native or CLI subagents when available and
    +falls back to the same slices sequentially. Items with no Loop assignment keep the ordinary direct
     `burnlist burn` workflow.
     
     See the [Loop CLI reference](website/src/content/docs/loops.mdx) for the exact
    diff --git a/loops/branch/branch.loop b/loops/branch/branch.loop
    new file mode 100644
    index 00000000..dfc7371d
    --- /dev/null
    +++ b/loops/branch/branch.loop
    @@ -0,0 +1,25 @@
    +
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +
    diff --git a/loops/branch/instructions.md b/loops/branch/instructions.md
    new file mode 100644
    index 00000000..1d304279
    --- /dev/null
    +++ b/loops/branch/instructions.md
    @@ -0,0 +1,23 @@
    +## plan
    +Turn the assigned item into a concise execution plan of independent subtasks.
    +Choose N from the work itself; prefer the fewest branches that create useful
    +parallelism. Define branch boundaries, shared constraints, and merge criteria.
    +
    +Do not edit Burnlist lifecycle files or complete the item directly.
    +
    +## branches
    +Execute the prepared independent subtasks through N smaller, faster agents when
    +the host supports subagents. Give each agent its exact slice and acceptance
    +criteria. Keep writes disjoint where possible. If native subagents are
    +unavailable, execute the same slices sequentially without changing the Loop
    +contract. Return only after every branch has a concrete result.
    +
    +## merge
    +Combine all branch results into one coherent candidate. Resolve overlaps,
    +finish integration work, and run focused checks before repository validation.
    +
    +## review
    +Independently review the merged, validated candidate against the original item
    +and plan. Approve when integration is coherent. Reject high-priority defects
    +and material medium-priority defects back to planning; leave minor refinements
    +as follow-up notes.
    diff --git a/loops/gate/gate.loop b/loops/gate/gate.loop
    new file mode 100644
    index 00000000..e2baec46
    --- /dev/null
    +++ b/loops/gate/gate.loop
    @@ -0,0 +1,17 @@
    +
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +
    diff --git a/loops/gate/instructions.md b/loops/gate/instructions.md
    new file mode 100644
    index 00000000..924bebbb
    --- /dev/null
    +++ b/loops/gate/instructions.md
    @@ -0,0 +1,7 @@
    +## implement
    +Implement the assigned item and prepare it for the declared deterministic gate.
    +Use the item contract to guide the change, but do not invent or bypass the
    +trusted repository check.
    +
    +Do not edit Burnlist lifecycle files or complete the item directly. Burnlist
    +owns the gate transition and completion.
    diff --git a/loops/review/instructions.md b/loops/review/instructions.md
    index 8079f335..4e5eabe0 100644
    --- a/loops/review/instructions.md
    +++ b/loops/review/instructions.md
    @@ -1,33 +1,11 @@
    -## start
    -Outline this decompose scope and identify candidate constraints.
    -
    -Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    -folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    -after convergence.
    -
    -## decompose
    -Break the next candidate into independent chunks and define success criteria.
    -
    -Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    -folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    -after convergence.
    -
     ## implement
    -Make a first-pass implementation that satisfies the decomposed chunks.
    +Implement the assigned item completely, run focused checks, and leave the
    +workspace ready for the declared repository validation.
     
    -Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    -folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    -after convergence.
    +Do not edit Burnlist lifecycle files or complete the item directly. Burnlist
    +owns transitions and completion.
     
     ## review
    -Review the current implementation for correctness and regression risk.
    -
    -## integrate
    -Merge reviewed work with baseline and resolve any follow-up.
    -
    -Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    -folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    -after convergence.
    -
    -## final-review
    -Do a final inspection for safety, quality, and handoff readiness.
    +Independently review the validated candidate against the item contract. Approve
    +when it is correct and proportionate. Reject only for actionable high-priority
    +or material medium-priority issues; record minor improvements for later.
    diff --git a/loops/review/review.loop b/loops/review/review.loop
    index de06888a..1db8f40b 100644
    --- a/loops/review/review.loop
    +++ b/loops/review/review.loop
    @@ -1,34 +1,21 @@
    -
    -  
    -  
    -  
    +
    +  
       
    -  
    -  
    -  
       
    -  
    -  
    +  
    +  
       
       
       
       
       
       
    -  
    -  
       
       
    -  
    -  
    -  
    +  
    +  
    +  
       
    -  
    -  
    -  
    -  
    -  
    -  
       
       
     
    diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md
    index 46854738..1bc14aa9 100644
    --- a/skills/burnlist/SKILL.md
    +++ b/skills/burnlist/SKILL.md
    @@ -155,12 +155,19 @@ Burnlist has two independent installable systems. Either or both may be present:
     
     Install only the system the task needs, or both. Read `references/installation.md` for exact commands, ownership, and shared-versus-local behavior.
     
    -## Built-in Review Loop (Stage 1)
    +## Built-in Loops (Stage 1)
     
    -Use the built-in `loop:builtin:review` only when a user or active Burnlist
    -assigns an item to it. It is one serial foreground path: maker, trusted
    -repository check, fresh reviewer, convergence gate, then CLI-owned completion.
    -Items without an assignment keep the direct Burnlist workflow.
    +Use only the execution choice assigned by the user or active Burnlist:
    +
    +- no assignment: direct implementation and ordinary `burnlist burn`
    +- `loop:builtin:review`: implement, trusted check, independent review, Burn
    +- `loop:builtin:gate`: implement, trusted check, Burn
    +- `loop:builtin:branch`: plan, host-orchestrated N smaller-agent slices, merge,
    +  trusted check, independent review, Burn
    +
    +Do not substitute one built-in for another. Branch fan-out is host-owned:
    +use native or CLI subagents when available, otherwise execute the declared
    +slices sequentially. Burnlist does not fabricate per-branch runtime authority.
     
     Before creating a Run, inspect and explicitly trust the repository check
     capability, then assign the item. Burnlist stores no provider profile, route,
    @@ -192,7 +199,7 @@ transition or execute a managed check.
     ```sh
     burnlist loop capability inspect repo-verify
     burnlist loop capability trust repo-verify --revision cp1-sha256: --grants 
    -burnlist loop assign item:# loop:builtin:review
    +burnlist loop assign item:# loop:builtin:
     ```
     
     Run `burnlist loop setup status` before `loop create`. Paste the complete
    diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs
    index 64d4f14b..edf76ea0 100644
    --- a/src/cli/commands-help.test.mjs
    +++ b/src/cli/commands-help.test.mjs
    @@ -187,7 +187,7 @@ test("Review Loop documentation command matrix runs against the production fixtu
       }
       const claim = loop("claim", runId);
       assert.equal(claim.status, 0, claim.stderr);
    -  assert.equal(JSON.parse(claim.stdout).execution.nodeId, "start");
    +  assert.equal(JSON.parse(claim.stdout).execution.nodeId, "implement");
       assert.equal(loop("abandon", JSON.parse(claim.stdout).execution.claimId,
         "--reason", "host-cancelled").status, 0);
     
    @@ -199,7 +199,7 @@ test("Review Loop documentation command matrix runs against the production fixtu
       assert.equal(JSON.parse(paused.stdout).state, "paused");
       const resumed = loop("claim", pausedRun);
       assert.equal(resumed.status, 0, resumed.stderr);
    -  assert.equal(JSON.parse(resumed.stdout).execution.nodeId, "start");
    +  assert.equal(JSON.parse(resumed.stdout).execution.nodeId, "implement");
     
       const stoppedRef = "item:260722-001#L32";
       assert.equal(loop("assign", stoppedRef, "loop:builtin:review").status, 0);
    @@ -212,8 +212,8 @@ test("Review Loop documentation command matrix runs against the production fixtu
       assert.equal(loop("assign", reconciledRef, "loop:builtin:review").status, 0);
       const reconciledRun = JSON.parse(loop("create", reconciledRef).stdout).runId;
       const store = runStore(repo), acquired = store.acquireLease(reconciledRun);
    -  store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "start", attempt: 1 });
    -  store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "start", attempt: 1, invocationId: "a".repeat(32) });
    +  store.append(reconciledRun, acquired.lease, "node-started", { nodeId: "implement", attempt: 1 });
    +  store.append(reconciledRun, acquired.lease, "invocation-started", { nodeId: "implement", attempt: 1, invocationId: "a".repeat(32) });
       const reconciled = loop("reconcile", reconciledRun, "--recovery-proof", acquired.recoveryProof);
       assert.equal(reconciled.status, 0, reconciled.stderr);
       assert.equal(JSON.parse(reconciled.stdout).state, "needs-human");
    diff --git a/src/cli/loop-runtime-cli.test.mjs b/src/cli/loop-runtime-cli.test.mjs
    index ea7448a1..f5d35e92 100644
    --- a/src/cli/loop-runtime-cli.test.mjs
    +++ b/src/cli/loop-runtime-cli.test.mjs
    @@ -41,7 +41,7 @@ test("host-only CLI exposes stable reads and no managed run command", (t) => {
       assert.equal(command(repo, ["list"]), "[]\n");
       const runId = created(repo);
       const status = JSON.parse(command(repo, ["status", runId]));
    -  assert.equal(status.currentNode, "start");
    +  assert.equal(status.currentNode, "implement");
       assert.equal(command(repo, ["status", runId]), command(repo, ["status", runId]));
       for (const verb of ["run", "resume"]) {
         const result = invoke(repo, [verb, runId]);
    @@ -66,8 +66,7 @@ test("claim and report advance checks and gates without launching a provider", (
         writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, outcome))}\n`);
         command(repo, ["report", execution.claimId, "--result", reportPath]);
       }
    -  assert.deepEqual(visited,
    -    ["start", "decompose", "implement", "review", "integrate", "final-review"]);
    +  assert.deepEqual(visited, ["implement", "review"]);
       assert.equal(JSON.parse(command(repo, ["status", runId])).state, "converged");
       assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, false);
       assert.equal(JSON.parse(command(repo, ["complete", runId])).alreadyApplied, true);
    @@ -92,7 +91,7 @@ test("a claimed provider cannot be stolen and reviewer candidate drift rejects i
       t.after(() => rmSync(directory, { recursive: true, force: true }));
       const { repo } = createProductionRunAuthority(join(directory, "repo"));
       const runId = created(repo), reportPath = join(directory, "report.json");
    -  for (const expected of ["start", "decompose", "implement"]) {
    +  for (const expected of ["implement"]) {
         const execution = JSON.parse(command(repo, ["claim", runId])).execution;
         assert.equal(execution.nodeId, expected);
         writeFileSync(reportPath, `${JSON.stringify(hostReport(execution, "complete"))}\n`);
    diff --git a/src/loops/assignment/assignment.mjs b/src/loops/assignment/assignment.mjs
    index 23fa3d31..3db9c5aa 100644
    --- a/src/loops/assignment/assignment.mjs
    +++ b/src/loops/assignment/assignment.mjs
    @@ -19,12 +19,12 @@ function atomicWrite(path, bytes) {
     }
     
     function packageDirectory(loop) {
    -  if (loop.name !== "review") fail(`installed Loop ${loop.selector} was not found`);
    -  return resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops/review");
    +  if (!["review", "gate", "branch"].includes(loop.name)) fail(`installed Loop ${loop.selector} was not found`);
    +  return resolve(dirname(fileURLToPath(import.meta.url)), "../../../loops", loop.name);
     }
     export async function resolveBuiltin(loop) {
       if (!loop.selector.startsWith("loop:builtin:")) fail(`installed Loop ${loop.selector} was not found`);
    -  const compiled = await compileLoopPackage(packageDirectory(loop));
    +  const compiled = await compileLoopPackage(packageDirectory(loop), { loopFile: `${loop.name}.loop` });
       if (!compiled.ok) fail(`installed Loop ${loop.selector} does not compile`);
       if (loop.executable && loop.executable !== compiled.revisions.executable) fail("LoopRef executable pin does not match current package");
       return compiled;
    diff --git a/src/loops/assignment/assignment.test.mjs b/src/loops/assignment/assignment.test.mjs
    index 0497248e..bc9dd448 100644
    --- a/src/loops/assignment/assignment.test.mjs
    +++ b/src/loops/assignment/assignment.test.mjs
    @@ -35,6 +35,15 @@ test("selectors are closed, disjoint, and reject noncanonical ULID overflow", ()
       for (const value of ["loop:project:../review", "loop:project:Review", "loop:local:review", "loop:project:review/extra"]) assert.throws(() => parseLoopRef(value), TypeError);
     });
     
    +test("the shipped Loop catalog contains only review, gate, and branch packages", async () => {
    +  for (const name of ["review", "gate", "branch"]) {
    +    const compiled = await resolveBuiltin(parseLoopRef(`loop:builtin:${name}`));
    +    assert.equal(compiled.ok, true);
    +    assert.equal(compiled.ir.id, name);
    +  }
    +  await assert.rejects(resolveBuiltin(parseLoopRef("loop:builtin:deep-review")), /was not found/u);
    +});
    +
     test("project Loop packages are contained, frozen, and show provenance drift without changing the item pin", async () => {
       const context = fixture();
       try {
    diff --git a/src/loops/dsl/__fixtures__/compiler-review/instructions.md b/src/loops/dsl/__fixtures__/compiler-review/instructions.md
    new file mode 100644
    index 00000000..8079f335
    --- /dev/null
    +++ b/src/loops/dsl/__fixtures__/compiler-review/instructions.md
    @@ -0,0 +1,33 @@
    +## start
    +Outline this decompose scope and identify candidate constraints.
    +
    +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    +after convergence.
    +
    +## decompose
    +Break the next candidate into independent chunks and define success criteria.
    +
    +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    +after convergence.
    +
    +## implement
    +Make a first-pass implementation that satisfies the decomposed chunks.
    +
    +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    +after convergence.
    +
    +## review
    +Review the current implementation for correctness and regression risk.
    +
    +## integrate
    +Merge reviewed work with baseline and resolve any follow-up.
    +
    +Do not edit Burnlist lifecycle files, delete the active item, move lifecycle
    +folders, or run `burnlist burn`/`loop complete`. Burnlist alone commits Burn
    +after convergence.
    +
    +## final-review
    +Do a final inspection for safety, quality, and handoff readiness.
    diff --git a/src/loops/dsl/__fixtures__/compiler-review/review.loop b/src/loops/dsl/__fixtures__/compiler-review/review.loop
    new file mode 100644
    index 00000000..de06888a
    --- /dev/null
    +++ b/src/loops/dsl/__fixtures__/compiler-review/review.loop
    @@ -0,0 +1,34 @@
    +
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +  
    +
    diff --git a/src/loops/dsl/compile.test.mjs b/src/loops/dsl/compile.test.mjs
    index deced2a8..ca4b2581 100644
    --- a/src/loops/dsl/compile.test.mjs
    +++ b/src/loops/dsl/compile.test.mjs
    @@ -13,7 +13,7 @@ import { foldRun } from "../run/run-fold.mjs";
     import { created } from "../run/m2-test-fixtures.mjs";
     import { renderDiagnostics } from "./diagnostics.mjs";
     
    -const root = new URL("../../../loops/review/", import.meta.url);
    +const root = new URL("./__fixtures__/compiler-review/", import.meta.url);
     const fixtures = new URL("./__fixtures__/", import.meta.url);
     async function reviewFiles() { return { "review.loop": await readFile(new URL("review.loop", root)), "instructions.md": await readFile(new URL("instructions.md", root)) }; }
     async function compiled() { const result = compileLoopFiles(await reviewFiles()); assert.equal(result.ok, true, renderDiagnostics(result.diagnostics ?? [])); return result; }
    @@ -30,7 +30,7 @@ test("built-in review package compiles to deterministic canonical frozen IR", as
       assert.deepEqual([...first.ir.nodes.map((node) => node.id)].sort(), ["completed", "converged", "decompose", "exhausted", "failed", "final-review", "final-validate", "implement", "integrate", "needs-human", "review", "start", "stopped", "validate"].sort());
       assert.deepEqual([...first.ir.edges.map((edge) => edge.from)].sort(), ["start", "decompose", "implement", "validate", "validate", "review", "review", "review", "integrate", "final-validate", "final-validate", "final-review", "final-review", "final-review", "converged", "converged"].sort());
       assert.match(first.revisions.executable, /^er1-sha256:[a-f0-9]{64}$/);
    -  assert.equal((await compileLoopPackage(new URL("../../../loops/review", import.meta.url).pathname)).ok, true);
    +  assert.equal((await compileLoopPackage(new URL("./__fixtures__/compiler-review", import.meta.url).pathname)).ok, true);
     });
     
     test("runtime consumes persisted frozen IR and validates recipe identity", async () => {
    diff --git a/src/loops/dsl/grammar.mjs b/src/loops/dsl/grammar.mjs
    index 0b01fe6d..fd56c5d1 100644
    --- a/src/loops/dsl/grammar.mjs
    +++ b/src/loops/dsl/grammar.mjs
    @@ -79,16 +79,15 @@ export function validateLoop(ast) {
       const agents = nodes.filter((node) => node.kind === "agent"), checks = nodes.filter((node) => node.kind === "check"), gates = nodes.filter((node) => node.kind === "gate"), terminals = nodes.filter((node) => node.kind === "terminal");
       const taskAgents = agents.filter((node) => node.mode === "task"), reviewAgents = agents.filter((node) => node.mode === "review");
       if (taskAgents.length < 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires at least one task agent");
    -  if (reviewAgents.length < 1) add(d, ast, "E_AGENT_CARDINALITY", "Stage 1 requires at least one review agent");
       if (checks.length < 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires at least one check node");
       if (gates.length !== 1) add(d, ast, "E_NODE_CARDINALITY", "Stage 1 requires exactly one convergence gate");
       for (const state of terminalStates) if (terminals.filter((node) => node.state === state).length !== 1) add(d, ast, "E_TERMINAL_CARDINALITY", `Stage 1 requires exactly one ${state} terminal`);
       for (const reviewer of reviewAgents) if (!taskAgents.some((task) => task.id === reviewer.independentFrom)) add(d, ids.get(reviewer.id), "E_REVIEW_INDEPENDENCE", "Reviewer independent-from must name an existing task agent");
       const gate = gates[0], gateRequires = new Set(gate?.requires ?? []);
       if (gate && (gateRequires.size !== gate.requires.length
    -    || !reviewAgents.some((node) => gateRequires.has(node.id))
    +    || ![...checks, ...reviewAgents].some((node) => gateRequires.has(node.id))
         || [...gateRequires].some((id) => !checks.some((node) => node.id === id) && !reviewAgents.some((node) => node.id === id)))) {
    -    add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must uniquely reference at least one review node and only check/review nodes");
    +    add(d, ids.get(gate.id), "E_GATE_REQUIREMENTS", "Convergence gate requires must uniquely reference at least one check/review node and only check/review nodes");
       }
       if (policy) for (const [outcome, state] of Object.entries({ error: "failed", timeout: "failed", cancelled: "stopped", lost: "needs-human", exhausted: "budget-exhausted" })) { const target = ids.get(policy.attrs[outcome]); if (!target || target.attrs.state !== state) add(d, policy, "E_FAILURE_POLICY", `${outcome} must target the ${state} terminal`); }
       for (const edge of edges) {
    @@ -102,7 +101,7 @@ export function validateLoop(ast) {
           (source?.kind === "agent" && source.mode === "review" && edge.on === "approve" && target?.kind !== "terminal") ||
           (source?.kind === "agent" && source.mode === "review" && edge.on === "reject" && target?.kind !== "terminal") ||
           (source?.kind === "agent" && source.mode === "review" && edge.on === "escalate" && target?.kind === "terminal" && target.state === "needs-human") ||
    -      (source?.kind === "check" && edge.on === "pass" && target?.kind === "agent" && target?.mode === "review") ||
    +      (source?.kind === "check" && edge.on === "pass" && (target?.kind === "gate" || target?.kind === "agent" && target?.mode === "review")) ||
           (source?.kind === "check" && edge.on === "fail" && target?.kind === "agent" && target?.mode === "task") ||
           (source?.kind === "gate" && edge.on === "pass" && target?.kind === "terminal" && target.state === "converged") ||
           (source?.kind === "gate" && edge.on === "fail" && target?.kind === "terminal" && target.state === "needs-human");
    diff --git a/src/loops/dsl/ir-validate.mjs b/src/loops/dsl/ir-validate.mjs
    index 1e2643d3..91549e33 100644
    --- a/src/loops/dsl/ir-validate.mjs
    +++ b/src/loops/dsl/ir-validate.mjs
    @@ -57,7 +57,7 @@ function backEdges(nodes, edges, entry) {
     
     function targetAllowed(source, outcome, target) {
       return (source.kind === "agent" && source.mode === "task" && outcome === "complete" && target.kind !== "terminal") ||
    -    (source.kind === "check" && outcome === "pass" && target.kind === "agent" && target.mode === "review") ||
    +    (source.kind === "check" && outcome === "pass" && (target.kind === "gate" || target.kind === "agent" && target.mode === "review")) ||
         (source.kind === "check" && outcome === "fail" && target.kind === "agent" && target.mode === "task") ||
         (source.kind === "agent" && source.mode === "review" && outcome === "reject" && target.kind !== "terminal") ||
         (source.kind === "agent" && source.mode === "review" && outcome === "approve" && target.kind !== "terminal") ||
    @@ -79,12 +79,12 @@ export function validateClosedIr(ir) {
       const terminals = ir.nodes.filter((node) => node.kind === "terminal");
       const agentInstructionIds = new Set(agents.map((agent) => agent.instructions));
     
    -  if (makers.length < 1 || reviewers.length < 1 || checks.length < 1 || gates.length !== 1 || states.some((state) => terminals.filter((node) => node.state === state).length !== 1)) return false;
    +  if (makers.length < 1 || checks.length < 1 || gates.length !== 1 || states.some((state) => terminals.filter((node) => node.state === state).length !== 1)) return false;
       if (ids.get(ir.entry)?.kind !== "agent" || ids.get(ir.entry)?.mode !== "task") return false;
       if (!reviewers.every((reviewer) => ids.get(reviewer.independentFrom)?.kind === "agent" && ids.get(reviewer.independentFrom)?.mode === "task")) return false;
       const gateRequires = new Set(gates[0].requires);
       if (gateRequires.size !== gates[0].requires.length
    -    || !gates[0].requires.some((id) => ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review")
    +    || !gates[0].requires.some((id) => ids.get(id)?.kind === "check" || ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review")
         || gates[0].requires.some((id) => ids.get(id)?.kind !== "check" && !(ids.get(id)?.kind === "agent" && ids.get(id)?.mode === "review"))) return false;
       if (agentInstructionIds.size !== agents.length) return false;
     
    diff --git a/src/loops/run/binder.mjs b/src/loops/run/binder.mjs
    index b07f82bc..109c49a4 100644
    --- a/src/loops/run/binder.mjs
    +++ b/src/loops/run/binder.mjs
    @@ -63,9 +63,11 @@ function assertBoundaryEvidence(evidence) {
       }
     }
     function executableSnapshot(loopRef) {
    -  if (loopRef !== "loop:builtin:review") fail(`executable source identity is unavailable for ${loopRef}`);
    -  const directory = join(builtinsRoot, "review"), files = {}, evidence = [];
    -  for (const [name, maximum] of [["review.loop", 65536], ["instructions.md", 262144]]) {
    +  const loop = parseLoopRef(loopRef);
    +  if (!loopRef.startsWith("loop:builtin:") || !["review", "gate", "branch"].includes(loop.name))
    +    fail(`executable source identity is unavailable for ${loopRef}`);
    +  const directory = join(builtinsRoot, loop.name), files = {}, evidence = [];
    +  for (const [name, maximum] of [[`${loop.name}.loop`, 65536], ["instructions.md", 262144]]) {
         const path = join(directory, name), captured = readSnapshotBytes({ root: builtinsRoot, path, maximum });
         files[name] = captured.bytes;
         evidence.push(Object.freeze({ kind: "file", path, dev: String(captured.identity.dev), ino: String(captured.identity.ino),
    @@ -75,7 +77,7 @@ function executableSnapshot(loopRef) {
           dev: String(ancestor.identity.dev), ino: String(ancestor.identity.ino), size: String(ancestor.identity.size),
           mode: String(ancestor.identity.mode), mtimeMs: String(ancestor.identity.mtimeMs), ctimeMs: String(ancestor.identity.ctimeMs) }));
       }
    -  const compiled = compileLoopFiles(files);
    +  const compiled = compileLoopFiles(files, { loopFile: `${loop.name}.loop` });
       if (!compiled.ok) fail("captured executable source does not compile");
       assertBoundaryEvidence(evidence);
       return { compiled, evidence };
    diff --git a/website/src/content/docs/cli.mdx b/website/src/content/docs/cli.mdx
    index eceb72cd..43165bc0 100644
    --- a/website/src/content/docs/cli.mdx
    +++ b/website/src/content/docs/cli.mdx
    @@ -37,7 +37,7 @@ These commands act on the current repository by default, or on an explicit `--re
     | `burnlist burn   [--check] [--repo ]` | Complete a Burnlist item; `--check` validates it. |
     | `burnlist close  [--repo ]` | Close a completed Burnlist. |
     
    -## Review Loop
    +## Loops
     
     ```sh
     burnlist loop assign   [--repo ]
    @@ -56,11 +56,14 @@ burnlist loop capability trust  --revision cp1-sha256: --grants ]
     ```
     
    -The Review Loop is an optional serial maker → check → fresh reviewer workflow.
    -Agent providers are invoked by the current host, never by Burnlist. `loop view`
    -is deterministic ASCII graph output; `claim` and `report` cross the provider
    -boundary, and reporting advances trusted checks and gates. Only `loop complete`
    -applies a converged Run to the Burnlist. See [Review Loop](/loops).
    +An item either runs directly with no assignment or uses one of exactly three
    +built-ins: `loop:builtin:review`, `loop:builtin:gate`, or
    +`loop:builtin:branch`. Branch fan-out is orchestrated by the current host;
    +Burnlist does not schedule branch workers. Agent providers are invoked by the
    +host, never by Burnlist. `loop view` is deterministic ASCII graph output;
    +`claim` and `report` cross the provider boundary, and reporting advances
    +trusted checks and gates. Only `loop complete` applies a converged Run to the
    +Burnlist. See [Loops](/loops).
     
     ## Registry
     
    diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx
    index f34cc307..c5539be1 100644
    --- a/website/src/content/docs/loops.mdx
    +++ b/website/src/content/docs/loops.mdx
    @@ -1,19 +1,26 @@
     ---
    -title: Review Loop
    -description: Configure and run the built-in serial maker, check, and reviewer Loop.
    +title: Loops
    +description: Choose direct execution or configure one of the three built-in Loop workflows.
     ---
     
    -The built-in `loop:builtin:review` is an optional serial, foreground workflow:
    +Every item uses one of exactly four execution choices:
     
     ```text
    -Item -> maker -> check -> fresh reviewer -> gate -> CLI completion
    -             ^             |
    -             +-- fail/reject
    +direct  Start -> Implement -> Burn
    +review  Start -> Implement -> Check -> Review -> Burn
    +gate    Start -> Implement -> Check -> Burn
    +branch  Start -> Plan -> N host-orchestrated branches -> Merge -> Check -> Review -> Burn
     ```
     
    -It does not replace normal Burnlist execution. An unassigned item uses the
    -direct workflow, including `burnlist burn   [--check]`; an assigned
    -item is completed only through its converged Loop Run.
    +Direct execution is represented by no Loop assignment and keeps the ordinary
    +`burnlist burn   [--check]` workflow. The three assignable built-ins
    +are `loop:builtin:review`, `loop:builtin:gate`, and `loop:builtin:branch`.
    +An assigned item is completed only through its converged Loop Run.
    +
    +Branch fan-out is host-orchestrated rather than a Burnlist scheduler: the plan
    +node chooses the slices, the host invokes N native or CLI subagents when
    +available (or executes the slices sequentially), and the merge node integrates
    +their results before the trusted check and independent review.
     
     ## Configure and assign
     
    @@ -63,7 +70,7 @@ narrow this policy.
     burnlist loop capability inspect repo-verify
     burnlist loop capability trust repo-verify --revision cp1-sha256: --grants /absolute/path/to/grants.json
     burnlist loop setup status
    -burnlist loop assign item:# loop:builtin:review
    +burnlist loop assign item:# loop:builtin:
     ```
     
     `setup status` reports missing configuration rather than guessing it. An
    
    From c34266287cee358380873e77f83538a91983e997 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 19:02:29 +0200
    Subject: [PATCH 21/23] fix: refine loop host usability
    
    ---
     bin/burnlist.mjs                             | 18 +++++++++++++++++
     skills/burnlist/SKILL.md                     |  5 +++++
     skills/burnlist/references/host-execution.md |  9 +++++++--
     skills/burnlist/references/installation.md   |  5 +++++
     src/cli/commands-help.test.mjs               |  1 +
     src/cli/loop-config-cli.mjs                  |  2 +-
     src/cli/skills-register.mjs                  | 15 +++++++++++++-
     src/cli/skills-register.test.mjs             | 21 ++++++++++++++++++++
     src/loops/dsl/package-read.test.mjs          |  6 +++---
     website/src/content/docs/loops.mdx           |  7 +++++++
     10 files changed, 82 insertions(+), 7 deletions(-)
    
    diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs
    index a9872125..ee34c67d 100755
    --- a/bin/burnlist.mjs
    +++ b/bin/burnlist.mjs
    @@ -36,6 +36,18 @@ function printSkillUsage(command) {
       console.log(`${usage}\n\nInstall and remove Burnlist-managed agent skills for Codex and Claude.`);
     }
     
    +function printLifecycleUsage(command) {
    +  const usages = {
    +    new: "Usage: burnlist new [--repo ]",
    +    show: "Usage: burnlist show [#] [--repo ]",
    +    ready: "Usage: burnlist ready  [--repo ]",
    +    start: "Usage: burnlist start  [--repo ]",
    +    close: "Usage: burnlist close  [--repo ]",
    +    burn: "Usage: burnlist burn   [--check] [--repo ]",
    +  };
    +  console.log(usages[command]);
    +}
    +
     async function main() {
     if (args[0] === "install" || args[0] === "uninstall") {
       if (args.includes("--help") || args.includes("-h")) {
    @@ -46,6 +58,12 @@ if (args[0] === "install" || args[0] === "uninstall") {
       return;
     }
     
    +if (["new", "show", "ready", "start", "close", "burn"].includes(args[0])
    +  && (args.includes("--help") || args.includes("-h"))) {
    +  printLifecycleUsage(args[0]);
    +  return;
    +}
    +
     if (args[0] === "differential-testing" && args[1] === "schema") {
       console.log(resolve(packageRoot, "ovens", "differential-testing", "engine", "data.schema.json"));
       return;
    diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md
    index 1bc14aa9..dedde694 100644
    --- a/skills/burnlist/SKILL.md
    +++ b/skills/burnlist/SKILL.md
    @@ -221,6 +221,8 @@ is not a Loop-visibility milestone.
     Inspect with `loop status|inspect`, control idle Runs with `loop pause|stop`,
     use proof-gated `loop reconcile` only for a demonstrably lost host claim, and
     apply a converged Run through idempotent `loop complete`.
    +`loop list` lists created Runs, not assignments; an empty result after
    +assignment is expected. Assignment truth is `loop view item:#`.
     
     The canonical Checklist Oven keeps a centered KPI row above the side-by-side
     Progress ledger and Completion trend. Its unified Items list contains current,
    @@ -241,6 +243,9 @@ It is not Docker or an OS sandbox. Parallelism, nested agents, metric gates,
     worktrees, background execution, Docker isolation, and
     forecasting are `unsupported`. The Checklist UI is read-only and displays
     active, paused, error, and terminal Run states; it never controls a Run.
    +Branch fan-out remains one canonical `branches` node in Stage 1, so N, slice
    +names, and native worker state are host evidence rather than dashboard graph
    +state.
     
     `burnlist install` registers this skill, while `burnlist hooks install` adds
     Streaming Diff edit-capture hooks. These integrations are independent and do
    diff --git a/skills/burnlist/references/host-execution.md b/skills/burnlist/references/host-execution.md
    index ce4136d6..15cbb9f5 100644
    --- a/skills/burnlist/references/host-execution.md
    +++ b/skills/burnlist/references/host-execution.md
    @@ -10,12 +10,17 @@ invocation. If subscriptions are unknown, read `loop-provider-setup.md` first.
     
        ```sh
        burnlist loop create item:#
    -   burnlist loop claim run:
    +   burnlist loop claim run: > .local/burnlist/claim.json
    +   node -e 'const c=require(process.argv[1]); console.log(c.claim.claimId)' \
    +     .local/burnlist/claim.json
        ```
     
        `claim` returns canonical JSON containing a `claim` and an `execution`
        envelope. Treat both as opaque, bounded, single-use authority. Do not alter
    -   or reconstruct their fields.
    +   or reconstruct their fields. Capture it to a private ignored file as above
    +   instead of printing the large authority envelope into chat. The second
    +   command prints the `ClaimRef`; it does not replace the saved execution
    +   envelope needed by the provider.
     
     2. Decode, inspect, and execute the exact prepared invocation. `execution` is
        a canonical `burnlist-loop-host-execution@1` object: base64-decode its
    diff --git a/skills/burnlist/references/installation.md b/skills/burnlist/references/installation.md
    index 33b05716..79243b96 100644
    --- a/skills/burnlist/references/installation.md
    +++ b/skills/burnlist/references/installation.md
    @@ -20,6 +20,11 @@ By default, `burnlist install` registers the bundled Burnlist skill for both age
     
     The default per-repository mode is a managed symlink and adds its target to `.git/info/exclude`, so it stays local and untracked. `--global` creates the managed global registrations instead. A global npm installation of Burnlist automatically registers those global skills for both agents. `--commit` is per-repository only: it creates a portable managed copy and removes Burnlist's local exclusion entry so the copy can be added to Git. `--force` permits an untracked managed copy to be downgraded to a symlink; tracked copies must be removed through Git first. `--agent codex`, `--agent claude`, or `--agent codex,claude` limits registrations; without it, both agents are targeted. `--dry-run` prints the planned link or copy operations without writing.
     
    +Codex uses the shared `~/.agents/skills` target. An older manually installed
    +`~/.codex/skills/burnlist` can shadow that registration in some clients. If
    +both exist, compare them before a Loop trial and remove or rename only the
    +stale manual copy; Burnlist never overwrites that foreign directory.
    +
     For a Git worktree, the command reports the default mode as `untracked (local, .git/info/exclude)`. For `--commit`, it checks copied content files and reports either `committable (portable copy; run git add to track)` or the actual ignore rule still hiding content. Global registrations report `global symlink (no repo exclude)`. A non-Git directory instead reports `symlink (no git repo to exclude into)` or `portable copy (no git repo)`.
     
     `burnlist uninstall` removes only Burnlist-managed registrations in the matching scope and removes its matching local exclusion entries. `--purge` requires `uninstall --global`, targets both agents, and also uninstalls the global npm package.
    diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs
    index edf76ea0..0cab4f24 100644
    --- a/src/cli/commands-help.test.mjs
    +++ b/src/cli/commands-help.test.mjs
    @@ -30,6 +30,7 @@ test("install, uninstall, and hooks subcommand help exit successfully with usage
           [["hooks", "install", "--help"], /Usage: burnlist hooks/u],
           [["hooks", "uninstall", "--help"], /Usage: burnlist hooks/u],
           [["hooks", "status", "--help"], /Usage: burnlist hooks/u],
    +      [["new", "--help"], /^Usage: burnlist new \[--repo \]/u],
         ]) {
           const result = run(context.directory, args);
           assert.equal(result.status, 0, args.join(" "));
    diff --git a/src/cli/loop-config-cli.mjs b/src/cli/loop-config-cli.mjs
    index 9dc2bec9..5642fdb4 100644
    --- a/src/cli/loop-config-cli.mjs
    +++ b/src/cli/loop-config-cli.mjs
    @@ -6,7 +6,7 @@ import { renderSetupStatus, setupStatus } from "../loops/config/setup.mjs";
     import { rawSha256 } from "../loops/dsl/hash.mjs";
     import { resolveUmbrella } from "./umbrella.mjs";
     
    -const loopUsage = "Usage: burnlist loop assign   [--repo ] | burnlist loop unassign  [--repo ] | burnlist loop view  [--repo ]\n       burnlist loop create  [--repo ]\n       burnlist loop next|claim  [--repo ]\n       burnlist loop report  (--outcome  | --result ) [--repo ]\n       burnlist loop abandon  --reason  [--repo ]\n       burnlist loop pause|stop|complete  [--repo ]\n       burnlist loop list [--repo ] | burnlist loop status|inspect  [--repo ]\n       burnlist loop reconcile  --recovery-proof  [--repo ]\n       burnlist loop capability inspect  [--repo ]\n       burnlist loop capability trust  --revision cp1-sha256: --grants  [--repo ]\n       burnlist loop setup status [--repo ]";
    +const loopUsage = "Usage: burnlist loop assign   [--repo ] | burnlist loop unassign  [--repo ] | burnlist loop view  [--repo ]\n       burnlist loop create  [--repo ]\n       burnlist loop next|claim  [--repo ]\n       burnlist loop report  (--outcome  | --result ) [--repo ]\n       burnlist loop abandon  --reason  [--repo ]\n       burnlist loop pause|stop|complete  [--repo ]\n       burnlist loop list [--repo ] (Runs only; use loop view  for assignments)\n       burnlist loop status|inspect  [--repo ]\n       burnlist loop reconcile  --recovery-proof  [--repo ]\n       burnlist loop capability inspect  [--repo ]\n       burnlist loop capability trust  --revision cp1-sha256: --grants  [--repo ]\n       burnlist loop setup status [--repo ]";
     
     function fail(message, exitCode = 2) { throw Object.assign(new Error(message), { exitCode }); }
     function parse(tokens, allowed = []) {
    diff --git a/src/cli/skills-register.mjs b/src/cli/skills-register.mjs
    index 137113c5..0f8a1237 100644
    --- a/src/cli/skills-register.mjs
    +++ b/src/cli/skills-register.mjs
    @@ -236,7 +236,19 @@ function formatGlobal(registration, dryRun) {
       return `Burnlist: ${verb} ${registration.source} -> ${registration.target}; global symlink (no repo exclude).`;
     }
     
    -export function registerSkills({ sourceRoot, scope = "repo", cwd = process.cwd(), env = process.env, agents, dryRun = false, commit = false, force = false, log = console.log, stageAtomic = stageAtomicText, beforeTargetMutation, afterExcludeWrite }) {
    +function warnLegacyCodexShadows(planned, env, warn) {
    +  const home = env.HOME || env.USERPROFILE;
    +  if (!home) return;
    +  for (const registration of planned.filter(({ agent }) => agent === "codex")) {
    +    const legacy = join(home, ".codex", "skills", registration.name);
    +    const stat = lstatOrNull(legacy);
    +    if (!stat) continue;
    +    if (stat.isSymbolicLink() && linkedSource(legacy) === registration.source) continue;
    +    warn(`Burnlist: ${legacy} may shadow the managed Codex skill at ${registration.target}; compare and remove or rename only the stale manual copy.`);
    +  }
    +}
    +
    +export function registerSkills({ sourceRoot, scope = "repo", cwd = process.cwd(), env = process.env, agents, dryRun = false, commit = false, force = false, log = console.log, warn = console.warn, stageAtomic = stageAtomicText, beforeTargetMutation, afterExcludeWrite }) {
       if (scope === "global" && commit) throw new Error("--commit is only valid for per-repository skill installs");
       const context = scope === "repo" ? gitContext(cwd) : null;
       const register = () => {
    @@ -302,6 +314,7 @@ export function registerSkills({ sourceRoot, scope = "repo", cwd = process.cwd()
           });
         }
         for (const registration of planned) log(scope === "global" ? formatGlobal(registration, dryRun) : formatInstall(registration, dryRun, commit));
    +    if (scope === "global") warnLegacyCodexShadows(planned, env, warn);
         return planned;
       };
       if (dryRun) return register();
    diff --git a/src/cli/skills-register.test.mjs b/src/cli/skills-register.test.mjs
    index 187153b9..8bd23743 100644
    --- a/src/cli/skills-register.test.mjs
    +++ b/src/cli/skills-register.test.mjs
    @@ -47,6 +47,27 @@ test("global dry-run describes Claude and Codex targets", () => {
       } finally { context.cleanup(); }
     });
     
    +test("global Codex registration warns when a legacy native skill can shadow it", () => {
    +  const context = fixture();
    +  try {
    +    const legacy = join(context.home, ".codex", "skills", "burnlist");
    +    mkdirSync(legacy, { recursive: true });
    +    writeFileSync(join(legacy, "SKILL.md"), "stale\n");
    +    const warnings = [];
    +    registerSkills({
    +      sourceRoot: join(repoRoot, "skills"),
    +      scope: "global",
    +      cwd: context.repo,
    +      env: { ...baseEnv, HOME: context.home, USERPROFILE: context.home },
    +      agents: ["codex"],
    +      log: () => {},
    +      warn: (message) => warnings.push(message),
    +    });
    +    assert.equal(warnings.length, 1);
    +    assert.match(warnings[0], /\.codex\/skills\/burnlist may shadow/u);
    +  } finally { context.cleanup(); }
    +});
    +
     test("repo dry-run describes both agent targets at the worktree root", () => {
       const context = fixture();
       try {
    diff --git a/src/loops/dsl/package-read.test.mjs b/src/loops/dsl/package-read.test.mjs
    index 7c1ba7b9..9eafd13a 100644
    --- a/src/loops/dsl/package-read.test.mjs
    +++ b/src/loops/dsl/package-read.test.mjs
    @@ -264,9 +264,9 @@ test("descriptor package diagnostics are merged and truncated after discovery, m
         const badCount = 97;
         const extras = Array.from({ length: badCount }, (_, index) => ` bad-${String(index).padStart(3, "0")}="x"`).join("");
         const malformed = files["review.loop"].toString()
    -      .replace('version="0.1.0"', "version=\"0\"")
    -      .replace('max-rounds="12"', `max-rounds="0"${extras}`)
    -      .replace('', '');
    +      .replace('version="0.2.0"', "version=\"0\"")
    +      .replace('max-rounds="6"', `max-rounds="0"${extras}`)
    +      .replace('', '');
     
         await writeFile(join(folder, "review.loop"), malformed);
         await writeFile(join(folder, "note.md"), "ignored\n");
    diff --git a/website/src/content/docs/loops.mdx b/website/src/content/docs/loops.mdx
    index c5539be1..107b52ad 100644
    --- a/website/src/content/docs/loops.mdx
    +++ b/website/src/content/docs/loops.mdx
    @@ -116,6 +116,13 @@ or execute checks. After a valid report, Burnlist automatically advances its
     trusted checks and graph-only nodes until the next agent claim or terminal
     state. The Checklist Oven only observes these updates.
     
    +`loop list` lists created Runs, not item assignments. Immediately after
    +assignment it may correctly return `[]`; use `loop view
    +item:#` to verify assignment truth. For shell-driven
    +hosts, redirect the large `loop claim` JSON to a private ignored file and
    +extract `.claim.claimId` from that file instead of printing the authority
    +envelope into chat.
    +
     The claim response carries a prepared execution envelope. A host executes that
     exact bounded input through its available mechanism, preserves its full claim
     identity in one `burnlist-loop-host-report@1`, and submits the report by claim
    
    From deb55998acfc63569774d637a14504a16b7348bb Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 19:15:09 +0200
    Subject: [PATCH 22/23] fix: compact loop feedback routing
    
    ---
     .../components/LoopGraph/LoopGraph.test.ts    | 55 +++++++++++++++++++
     .../LoopGraph/serial-compact-layout.ts        | 50 ++++++++---------
     2 files changed, 77 insertions(+), 28 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index d6b27edc..a7295d16 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -146,3 +146,58 @@ test("compact topology keeps the serial dogfood Loop labelled within tablet widt
       assert.ok(narrow.lines.length > wide.lines.length);
       assert.ok(new Set([...extraWide.positions.values()].map((position) => position.y)).size >= 2);
     });
    +
    +test("canonical review and branch loops use balanced rows with local feedback", () => {
    +  const serial = (ids: string[], edges: LoopGraphProjection["graph"]["edges"]) => {
    +    const run = projection({
    +      graph: {
    +        entry: "start",
    +        nodes: ids.map((id) => ({
    +          id,
    +          kind: id === "validate" ? "check"
    +            : id === "converged" ? "gate"
    +              : id === "completed" ? "terminal" : "agent",
    +          role: id === "review" ? "reviewer" : "maker",
    +          terminalState: id === "completed" ? "converged" : undefined,
    +        })),
    +        edges,
    +      },
    +    });
    +    return layoutCompactLoop(run, { availableCharacters: 72, showLabels: true }).lines;
    +  };
    +  const review = serial(
    +    ["start", "implement", "validate", "review", "converged", "completed"],
    +    [
    +      { from: "start", on: "begin", to: "implement" },
    +      { from: "implement", on: "complete", to: "validate" },
    +      { from: "validate", on: "pass", to: "review" },
    +      { from: "validate", on: "fail", to: "implement" },
    +      { from: "review", on: "approve", to: "converged" },
    +      { from: "review", on: "reject", to: "implement" },
    +      { from: "converged", on: "pass", to: "completed" },
    +    ],
    +  );
    +  const branch = serial(
    +    ["start", "plan", "branches", "merge", "validate", "review", "converged", "completed"],
    +    [
    +      { from: "start", on: "begin", to: "plan" },
    +      { from: "plan", on: "complete", to: "branches" },
    +      { from: "branches", on: "complete", to: "merge" },
    +      { from: "merge", on: "complete", to: "validate" },
    +      { from: "validate", on: "pass", to: "review" },
    +      { from: "validate", on: "fail", to: "branches" },
    +      { from: "review", on: "approve", to: "converged" },
    +      { from: "review", on: "reject", to: "plan" },
    +      { from: "converged", on: "pass", to: "completed" },
    +    ],
    +  );
    +  for (const lines of [review, branch]) {
    +    assert.ok(Math.max(...lines.map((line) => line.length)) <= 72);
    +    assert.ok(lines.length <= 7);
    +    assert.doesNotMatch(lines.join("\n"), /^[┌├└]/mu);
    +    assert.match(lines[0], /^S .*▶/u);
    +    assert.match(lines.at(-1)!, /^\s*C .*G/u);
    +  }
    +  assert.match(review.join("\n"), /fail.*reject/su);
    +  assert.match(branch.join("\n"), /fail.*reject|reject.*fail/su);
    +});
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    index ce1451e8..0e791631 100644
    --- a/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -76,36 +76,31 @@ export function layoutSerialCompact(
         ? Math.max(0, ...primary.map((edge) => edge.on.length)) : 0;
       const slot = Math.max(9, labelWidth + 9, ...path.map((id) => symbols.get(id)!.length + 7));
       const available = Math.max(24, options.availableCharacters ?? 72);
    -  const destinations = [...new Set(feedback.map((edge) => edge.to))];
    -  const leftMargin = destinations.length ? destinations.length * 2 + 2 : 0;
    -  const minimumRows = path.length >= 7 ? 2 : 1;
       const candidates = Array.from(
    -    { length: Math.min(5, Math.ceil(path.length / 2)) - minimumRows + 1 },
    -    (_, index) => minimumRows + index,
    +    { length: Math.max(1, Math.min(5, path.length) - 1) },
    +    (_, index) => index + 2,
       );
    -  const rowCount = candidates.map((rows) => {
    -    const columns = Math.ceil(path.length / rows);
    -    const estimatedWidth = leftMargin + (columns - 1) * slot + 2;
    +  const perRow = candidates.map((columns) => {
    +    const rows = Math.ceil(path.length / columns);
    +    const estimatedWidth = (columns - 1) * slot + 2;
         const estimatedHeight = rows * 5;
         const overflow = Math.max(0, estimatedWidth - available);
         const aspect = estimatedWidth / Math.max(1, estimatedHeight * 2);
    -    const emptySlots = rows * columns - path.length;
         return {
    -      rows,
    +      columns,
           score: overflow * 1_000
             + Math.abs(Math.log(Math.max(.01, aspect) / 2.2)) * 20
    -        + emptySlots * 1.5 + rows * .15,
    +        + Math.abs(columns - 4) * 2 + rows * .15,
         };
    -  }).sort((left, right) => left.score - right.score || left.rows - right.rows)[0].rows;
    -  const perRow = Math.ceil(path.length / rowCount);
    +  }).sort((left, right) => left.score - right.score || right.columns - left.columns)[0].columns;
    +  const rowCount = Math.ceil(path.length / perRow);
       const rowHeight = 4 + Math.max(0, ...Array.from({ length: rowCount }, (_, row) =>
    -    feedback.filter((edge) => Math.floor(pathIndex.get(edge.from)! / perRow) === row).length));
    +    feedback.filter((edge) => Math.floor(pathIndex.get(edge.to)! / perRow) === row).length));
       const positions = new Map();
       path.forEach((id, index) => {
         const row = Math.floor(index / perRow), offset = index % perRow;
    -    const count = Math.min(perRow, path.length - row * perRow);
    -    const column = row % 2 ? count - offset - 1 : offset;
    -    positions.set(id, { x: leftMargin + column * slot, y: row * rowHeight });
    +    const column = row % 2 ? perRow - offset - 1 : offset;
    +    positions.set(id, { x: column * slot, y: row * rowHeight });
       });
       const width = Math.min(available, Math.max(...[...positions.values()].map((point) => point.x)) + slot);
       const graph = canvas(rowCount * rowHeight, width);
    @@ -125,21 +120,20 @@ export function layoutSerialCompact(
         }
       });
     
    -  const rails = new Map(destinations.map((id, index) => [id, index * 2]));
    -  const sourceOffsets = new Map();
    +  const targetRowOffsets = new Map();
       feedback.forEach((edge) => {
         const from = positions.get(edge.from)!, to = positions.get(edge.to)!;
    -    const row = Math.floor(pathIndex.get(edge.from)! / perRow);
    -    const offset = sourceOffsets.get(row) ?? 0;
    -    sourceOffsets.set(row, offset + 1);
    -    const sourceY = from.y + 2 + offset, railX = rails.get(edge.to)!;
    -    graph.vertical(from.x, from.y + 1, sourceY);
    -    graph.horizontal(railX, from.x, sourceY);
    -    graph.vertical(railX, to.y + 1, sourceY);
    -    graph.horizontal(railX, to.x, to.y + 1);
    +    const targetRow = Math.floor(pathIndex.get(edge.to)! / perRow);
    +    const offset = targetRowOffsets.get(targetRow) ?? 0;
    +    targetRowOffsets.set(targetRow, offset + 1);
    +    const feedbackY = to.y + 2 + offset;
    +    const fromAttachY = feedbackY < from.y ? from.y - 1 : from.y + 1;
    +    graph.vertical(from.x, Math.min(fromAttachY, feedbackY), Math.max(fromAttachY, feedbackY));
    +    graph.horizontal(to.x, from.x, feedbackY);
    +    graph.vertical(to.x, to.y + 1, feedbackY);
         graph.put(to.x, to.y + 1, "▲");
         if (options.showLabels)
    -      placeHorizontalLabel(graph, railX, from.x, sourceY, edge.on);
    +      placeHorizontalLabel(graph, to.x, from.x, feedbackY, edge.on);
       });
     
       for (const [id, position] of positions) graph.text(position.x, position.y, symbols.get(id)!);
    
    From 8fca0712927ebf1a31130f61ac49ca6d56c42848 Mon Sep 17 00:00:00 2001
    From: Juan Cruz Fortunatti 
    Date: Sat, 25 Jul 2026 19:21:41 +0200
    Subject: [PATCH 23/23] fix: keep loop labels clear of return paths
    
    ---
     dashboard/src/components/LoopGraph/LoopGraph.test.ts  |  6 ++++--
     .../src/components/LoopGraph/serial-compact-layout.ts | 11 ++++++++++-
     2 files changed, 14 insertions(+), 3 deletions(-)
    
    diff --git a/dashboard/src/components/LoopGraph/LoopGraph.test.ts b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    index a7295d16..0d55022a 100644
    --- a/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    +++ b/dashboard/src/components/LoopGraph/LoopGraph.test.ts
    @@ -192,12 +192,14 @@ test("canonical review and branch loops use balanced rows with local feedback",
         ],
       );
       for (const lines of [review, branch]) {
    +    const drawing = lines.join("\n");
         assert.ok(Math.max(...lines.map((line) => line.length)) <= 72);
         assert.ok(lines.length <= 7);
    -    assert.doesNotMatch(lines.join("\n"), /^[┌├└]/mu);
    +    assert.doesNotMatch(drawing, /^[┌├└]/mu);
    +    assert.doesNotMatch(drawing, /rej[│┼├┤┬┴]ect|fa[│┼├┤┬┴]il/u);
         assert.match(lines[0], /^S .*▶/u);
         assert.match(lines.at(-1)!, /^\s*C .*G/u);
       }
       assert.match(review.join("\n"), /fail.*reject/su);
    -  assert.match(branch.join("\n"), /fail.*reject|reject.*fail/su);
    +  assert.match(branch.join("\n"), /fail.*reject/su);
     });
    diff --git a/dashboard/src/components/LoopGraph/serial-compact-layout.ts b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    index 0e791631..09663df2 100644
    --- a/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    +++ b/dashboard/src/components/LoopGraph/serial-compact-layout.ts
    @@ -102,6 +102,15 @@ export function layoutSerialCompact(
         const column = row % 2 ? perRow - offset - 1 : offset;
         positions.set(id, { x: column * slot, y: row * rowHeight });
       });
    +  const routedFeedback = [...feedback].sort((left, right) => {
    +    const leftFrom = positions.get(left.from)!, leftTo = positions.get(left.to)!;
    +    const rightFrom = positions.get(right.from)!, rightTo = positions.get(right.to)!;
    +    const leftSpan = Math.abs(leftFrom.x - leftTo.x);
    +    const rightSpan = Math.abs(rightFrom.x - rightTo.x);
    +    return leftSpan - rightSpan
    +      || rightTo.x - leftTo.x
    +      || pathIndex.get(left.from)! - pathIndex.get(right.from)!;
    +  });
       const width = Math.min(available, Math.max(...[...positions.values()].map((point) => point.x)) + slot);
       const graph = canvas(rowCount * rowHeight, width);
     
    @@ -121,7 +130,7 @@ export function layoutSerialCompact(
       });
     
       const targetRowOffsets = new Map();
    -  feedback.forEach((edge) => {
    +  routedFeedback.forEach((edge) => {
         const from = positions.get(edge.from)!, to = positions.get(edge.to)!;
         const targetRow = Math.floor(pathIndex.get(edge.to)! / perRow);
         const offset = targetRowOffsets.get(targetRow) ?? 0;