From e3a755a35ccad70c6d056394f56e988a9569230f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 19:49:24 +0200 Subject: [PATCH 01/21] feat: make oven semver version first-class identity id@version (260721-001) --- .../CustomOvenView/custom-oven-render.test.mjs | 2 +- .../ChecklistWidgets/ChecklistWidgets.test.ts | 4 ++-- .../src/oven/runtime/log-table-adapter.test.ts | 2 +- .../src/oven/runtime/lower-oven-ir.test.ts | 2 +- ovens/checklist/checklist.oven | 2 +- .../differential-testing.oven | 2 +- ovens/model-lab/model-lab.oven | 2 +- .../performance-tracing.oven | 2 +- ovens/streaming-diff/streaming-diff.oven | 2 +- ovens/visual-parity/visual-parity.oven | 2 +- skills/burnlist/references/creating-ovens.md | 4 ++-- skills/burnlist/references/designing-ovens.md | 2 +- skills/burnlist/references/oven-authoring.md | 2 +- src/cli/oven-cli-stdout.test.mjs | 2 +- src/cli/oven-cli.mjs | 10 ++++++---- src/cli/oven-cli.test.mjs | 10 +++++++--- src/cli/oven-storage.test.mjs | 2 +- src/ovens/dsl/__fixtures__/checklist.oven | 2 +- .../dsl/__fixtures__/differential-testing.oven | 2 +- src/ovens/dsl/oven-compile.test.mjs | 18 +++++++++++++++++- src/ovens/dsl/oven-ir.mjs | 4 ++-- src/ovens/dsl/oven-validate.mjs | 4 ++-- src/ovens/dsl/oven-validate.test.mjs | 4 ++-- src/ovens/oven-contract.mjs | 6 +++++- src/ovens/oven-contract.test.mjs | 11 ++++++++++- src/ovens/oven-starter.mjs | 2 +- src/server/burnlist-dashboard-server.mjs | 1 + src/server/custom-oven-index.test.mjs | 2 +- src/server/custom-oven-view-routes.test.mjs | 2 +- src/server/oven-routes.test.mjs | 2 +- website/src/content/docs/ovens/authoring.mdx | 4 ++-- .../src/content/docs/ovens/dsl-reference.mdx | 2 +- website/src/content/docs/ovens/oven-system.mdx | 2 +- 33 files changed, 79 insertions(+), 43 deletions(-) diff --git a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs index 4772656..35567e6 100644 --- a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs +++ b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs @@ -11,7 +11,7 @@ const runtimePath = new URL("../../oven/runtime/OvenRuntime.tsx", import.meta.ur const sourceDir = new URL("../../", import.meta.url).pathname; const libPath = new URL("../../lib", import.meta.url).pathname; const ovenPath = new URL("../../oven", import.meta.url).pathname; -const ovenSource = ` +const ovenSource = ` diff --git a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts index e88cb8b..7bc639e 100644 --- a/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts +++ b/dashboard/src/oven/ChecklistWidgets/ChecklistWidgets.test.ts @@ -23,7 +23,7 @@ test("checklist widget adapters preserve the exported dashboard subregions", () }); test("box lowering preserves element, class, id, text, and children", () => { - const result = compileOven(''); +const result = compileOven(''); assert.equal(result.ok, true, result.ok ? "" : JSON.stringify(result.diagnostics)); const state = initOvenState(ir, {}); const html = renderToStaticMarkup(createElement(OvenNode, { node: result.ir.root[0], ir, state, dispatch: () => {} })); @@ -31,7 +31,7 @@ test("box lowering preserves element, class, id, text, and children", () => { }); test("checklist declarative vocabulary and passthrough attributes compile", () => { - const source = ''; + const source = ''; const result = compileOven(source); assert.equal(result.ok, true, result.ok ? "" : JSON.stringify(result.diagnostics)); }); diff --git a/dashboard/src/oven/runtime/log-table-adapter.test.ts b/dashboard/src/oven/runtime/log-table-adapter.test.ts index e91fe7b..f513331 100644 --- a/dashboard/src/oven/runtime/log-table-adapter.test.ts +++ b/dashboard/src/oven/runtime/log-table-adapter.test.ts @@ -11,7 +11,7 @@ import { buildLogTableProps } from "./log-table-adapter"; import { OvenNode } from "./OvenNode"; import { initOvenState, type OvenIr } from "./oven-reducer"; -const source = ``; +const source = ``; const compiled = compileOven(source); if (!compiled.ok) throw new Error(compiled.diagnostics.map((item: { message: string }) => item.message).join("\n")); const table = compiled.ir.root[0].children[0]; diff --git a/dashboard/src/oven/runtime/lower-oven-ir.test.ts b/dashboard/src/oven/runtime/lower-oven-ir.test.ts index 7f2a285..b6dd609 100644 --- a/dashboard/src/oven/runtime/lower-oven-ir.test.ts +++ b/dashboard/src/oven/runtime/lower-oven-ir.test.ts @@ -13,7 +13,7 @@ import { assertDomEquivalent, extractFirstByClass } from "../test-support/dom-no import { lowerOvenIr } from "./lower-oven-ir"; const payload = { value: 7, ratio: 0.123, items: ["a", "b"] }; -const open = ''; +const open = ''; function lower(fragment: string) { const result = compileOven(`${open}${fragment}`); diff --git a/ovens/checklist/checklist.oven b/ovens/checklist/checklist.oven index 8737732..023f20c 100644 --- a/ovens/checklist/checklist.oven +++ b/ovens/checklist/checklist.oven @@ -1,4 +1,4 @@ - + diff --git a/ovens/differential-testing/differential-testing.oven b/ovens/differential-testing/differential-testing.oven index e6803ee..9a57226 100644 --- a/ovens/differential-testing/differential-testing.oven +++ b/ovens/differential-testing/differential-testing.oven @@ -1,5 +1,5 @@ diff --git a/ovens/model-lab/model-lab.oven b/ovens/model-lab/model-lab.oven index e5b4c04..1eb3772 100644 --- a/ovens/model-lab/model-lab.oven +++ b/ovens/model-lab/model-lab.oven @@ -1,3 +1,3 @@ - + diff --git a/ovens/performance-tracing/performance-tracing.oven b/ovens/performance-tracing/performance-tracing.oven index 42f0f7a..1e3e19d 100644 --- a/ovens/performance-tracing/performance-tracing.oven +++ b/ovens/performance-tracing/performance-tracing.oven @@ -1,5 +1,5 @@ diff --git a/ovens/streaming-diff/streaming-diff.oven b/ovens/streaming-diff/streaming-diff.oven index 751eaf6..f3913bd 100644 --- a/ovens/streaming-diff/streaming-diff.oven +++ b/ovens/streaming-diff/streaming-diff.oven @@ -1,4 +1,4 @@ - + diff --git a/ovens/visual-parity/visual-parity.oven b/ovens/visual-parity/visual-parity.oven index 8e1540e..4215a2b 100644 --- a/ovens/visual-parity/visual-parity.oven +++ b/ovens/visual-parity/visual-parity.oven @@ -1,4 +1,4 @@ - + diff --git a/skills/burnlist/references/creating-ovens.md b/skills/burnlist/references/creating-ovens.md index e076eb4..2d8a6a1 100644 --- a/skills/burnlist/references/creating-ovens.md +++ b/skills/burnlist/references/creating-ovens.md @@ -52,7 +52,7 @@ Every source has exactly one root: ```xml @@ -236,7 +236,7 @@ are in `references/oven-authoring.md`. Here is a complete generic KPI-and-table source, `kpi.oven`: ```xml - + diff --git a/skills/burnlist/references/designing-ovens.md b/skills/burnlist/references/designing-ovens.md index 3514434..9395f0b 100644 --- a/skills/burnlist/references/designing-ovens.md +++ b/skills/burnlist/references/designing-ovens.md @@ -74,7 +74,7 @@ await writeFile(new URL('migration-status-data.json', out), JSON.stringify({ The Oven that binds to that document reads each pointer by name: ```xml - + diff --git a/skills/burnlist/references/oven-authoring.md b/skills/burnlist/references/oven-authoring.md index 2208a3e..c41136e 100644 --- a/skills/burnlist/references/oven-authoring.md +++ b/skills/burnlist/references/oven-authoring.md @@ -180,7 +180,7 @@ An Oven that observes the role-separated execution loop for one Burnlist item. Its `.oven` source reads the `/loop/*` document the orchestrator adapter serves: ```xml - + diff --git a/src/cli/oven-cli-stdout.test.mjs b/src/cli/oven-cli-stdout.test.mjs index 0aea282..cd9e160 100644 --- a/src/cli/oven-cli-stdout.test.mjs +++ b/src/cli/oven-cli-stdout.test.mjs @@ -9,7 +9,7 @@ const repoRoot = resolve(new URL("../..", import.meta.url).pathname); const binPath = join(repoRoot, "bin", "burnlist.mjs"); function largeOvenSource() { - return ` + return ` diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index c661e9c..787f8fa 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -95,9 +95,9 @@ function printOven(oven) { }; countNodes(oven.ir.root); const kind = oven.builtIn ? "built-in" : "custom"; - console.log(`${oven.name} (${oven.id} · ${kind})`); + console.log(`${oven.name} (${oven.id}@${oven.ir.version} · ${kind})`); if (oven.description) console.log(oven.description); - console.log(`nodes: ${nodeCount} · contract: ${oven.ir.contract} · theme: ${oven.ir.theme}`); + console.log(`version: ${oven.ir.version} · nodes: ${nodeCount} · contract: ${oven.ir.contract} · theme: ${oven.ir.theme}`); console.log(`revision: ${oven.ovenRevision}`); console.log(`path: ${oven.path}`); console.log(""); @@ -174,7 +174,7 @@ try { if (subcommand === "list") { const ovens = discoverOvens(); if (flags.has("json")) { - console.log(JSON.stringify(ovens.map(({ instructions, ir, ...rest }) => rest), null, 2)); + console.log(JSON.stringify(ovens.map(({ instructions, ir, ...rest }) => ({ ...rest, version: ir.version })), null, 2)); return; } if (ovens.length === 0) { @@ -184,13 +184,14 @@ try { const nodeCount = (nodes) => nodes.reduce((count, node) => count + 1 + nodeCount(node.children), 0); const rows = ovens.map((oven) => [ oven.id, + oven.ir.version, oven.name, oven.builtIn ? "built-in" : "custom", oven.ir.contract, String(nodeCount(oven.ir.root)), oven.ovenRevision, ]); - const header = ["id", "name", "kind", "contract", "nodes", "revision"]; + const header = ["id", "version", "name", "kind", "contract", "nodes", "revision"]; const widths = header.map((label, index) => Math.max(label.length, ...rows.map((row) => row[index].length))); const line = (cols) => cols.map((value, index) => value.padEnd(widths[index])).join(" ").trimEnd(); console.log(line(header)); @@ -207,6 +208,7 @@ try { if (flags.has("json")) { console.log(JSON.stringify({ id: oven.id, + version: oven.ir.version, name: oven.name, builtIn: oven.builtIn, instructions: oven.instructions, diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index 134b456..78a4aea 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -175,15 +175,19 @@ test("oven view and list render IR structure and source packages", () => { try { writeOven(ovensDir, "rendered-oven"); const view = run(context, "oven", "view", "rendered-oven", "--ovens-dir", ovensDir); - assert.match(view, /nodes: 1 · contract: checklist-progress@1 · theme: checklist/u); + assert.match(view, /Forked Oven \(rendered-oven@0\.1\.0 · custom\)/u); + assert.match(view, /version: 0\.1\.0 · nodes: 1 · contract: checklist-progress@1 · theme: checklist/u); assert.match(view, /section-header\n\nnode prop source\n/u); const list = run(context, "oven", "list", "--ovens-dir", ovensDir); - assert.match(list, /^id\s+name\s+kind\s+contract\s+nodes\s+revision$/mu); - assert.match(list, /^rendered-oven\s+Forked Oven\s+custom\s+checklist-progress@1\s+1\s+/mu); + assert.match(list, /^id\s+version\s+name\s+kind\s+contract\s+nodes\s+revision$/mu); + assert.match(list, /^rendered-oven\s+0\.1\.0\s+Forked Oven\s+custom\s+checklist-progress@1\s+1\s+/mu); const json = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)); const oven = json.find((item) => item.id === "rendered-oven"); assert.equal(oven.oven, ovenFixture("rendered-oven")); + assert.equal(oven.version, "0.1.0"); assert.equal(Object.hasOwn(oven, "ir"), false); + const viewJson = JSON.parse(run(context, "oven", "view", "rendered-oven", "--json", "--ovens-dir", ovensDir)); + assert.equal(viewJson.version, "0.1.0"); } finally { context.cleanup(); } }); diff --git a/src/cli/oven-storage.test.mjs b/src/cli/oven-storage.test.mjs index 4845993..d4c9ac7 100644 --- a/src/cli/oven-storage.test.mjs +++ b/src/cli/oven-storage.test.mjs @@ -21,7 +21,7 @@ function run(context, args, input) { } function ovenWithStoredBytes(targetBytes, id) { - const prefix = `\n \n \n\n'; const fillBytes = targetBytes - Buffer.byteLength(prefix) - Buffer.byteLength(suffix); assert.ok(fillBytes >= 0, "test fixture must leave room for a valid Oven"); diff --git a/src/ovens/dsl/__fixtures__/checklist.oven b/src/ovens/dsl/__fixtures__/checklist.oven index 6e7e413..8a0d0fe 100644 --- a/src/ovens/dsl/__fixtures__/checklist.oven +++ b/src/ovens/dsl/__fixtures__/checklist.oven @@ -1,7 +1,7 @@ diff --git a/src/ovens/dsl/__fixtures__/differential-testing.oven b/src/ovens/dsl/__fixtures__/differential-testing.oven index 32fe368..451082d 100644 --- a/src/ovens/dsl/__fixtures__/differential-testing.oven +++ b/src/ovens/dsl/__fixtures__/differential-testing.oven @@ -1,7 +1,7 @@ diff --git a/src/ovens/dsl/oven-compile.test.mjs b/src/ovens/dsl/oven-compile.test.mjs index ea30764..a70eb07 100644 --- a/src/ovens/dsl/oven-compile.test.mjs +++ b/src/ovens/dsl/oven-compile.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { compileOvenFile } from "./oven-compile.mjs"; +import { compileOven, compileOvenFile } from "./oven-compile.mjs"; function frozen(value) { if (!value || typeof value !== "object") return true; return Object.isFrozen(value) && Object.values(value).every(frozen); } for (const [name, expected] of Object.entries({ checklist: { components: ["kpi-item", "log-table", "progress-donut", "section-header"], icons: ["ClipboardList", "Gauge"] }, "differential-testing": { components: ["burn-donut", "field-list", "kpi-strip", "waffle-metric"], selectors: ["changed", "non-pass"] } })) test(`${name} golden compiles to frozen JSON-safe IR`, async () => { @@ -12,3 +12,19 @@ for (const [name, expected] of Object.entries({ checklist: { components: ["kpi-i assert.deepEqual(JSON.parse(JSON.stringify(result.ir)), result.ir); for (const [key, values] of Object.entries(expected)) for (const value of values) assert.ok(result.ir.requirements[key].includes(value)); }); + +test("oven version is a semver string", () => { + const source = ''; + const result = compileOven(source); + assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); + assert.equal(result.ir.version, "0.1.0"); +}); + +test("oven version requires major.minor.patch", () => { + for (const version of ["1", "1.0"]) { + const source = ``; + const result = compileOven(source); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === "SCALAR_VERSION"), JSON.stringify(result.diagnostics)); + } +}); diff --git a/src/ovens/dsl/oven-ir.mjs b/src/ovens/dsl/oven-ir.mjs index dbc2d5c..223d91e 100644 --- a/src/ovens/dsl/oven-ir.mjs +++ b/src/ovens/dsl/oven-ir.mjs @@ -6,7 +6,7 @@ export function assertIR(ir) { const check = (value) => { if (value === null || ["string", "number", "boolean"].includes(typeof value)) return; if (Array.isArray(value)) return value.forEach(check); const proto = value && Object.getPrototypeOf(value); if (typeof value !== "object" || (proto !== Object.prototype && proto !== null)) throw new TypeError("IR must contain JSON data only"); for (const item of Object.values(value)) check(item); }; check(ir); return ir; } -function typed(attrs) { const out = {}; for (const [key, value] of Object.entries(attrs)) out[toCamel(key)] = ["version", "refresh-seconds", "columns", "rows", "row-height", "column", "row", "column-span", "row-span", "page-size", "debounce-ms"].includes(key) ? Number(value) : (["optional", "default"].includes(key) ? value === "true" : value); return out; } +function typed(attrs) { const out = {}; for (const [key, value] of Object.entries(attrs)) out[toCamel(key)] = ["refresh-seconds", "columns", "rows", "row-height", "column", "row", "column-span", "row-span", "page-size", "debounce-ms"].includes(key) ? Number(value) : (["optional", "default"].includes(key) ? value === "true" : value); return out; } const toCamel = (key) => key.replace(/-([a-z])/g, (_, x) => x.toUpperCase()); export function buildIR(ast) { const requirements = { components: new Set(), formats: new Set(), icons: new Set(), selectors: new Set() }; @@ -14,6 +14,6 @@ export function buildIR(ast) { const root = ast.children.map(convert), nodes = []; const visit = (n) => { nodes.push(n); n.children.forEach(visit); }; root.forEach(visit); const controls = nodes.filter((n) => ["mode-toggle","search","sort-toggle","filter-toggle","domain-tabs"].includes(n.kind)).map((n) => ({ kind: n.kind, id: n.attributes.id, ...n.attributes })); const collections = nodes.filter((n) => n.kind === "collection").map((n) => ({ id: n.attributes.id, source: n.attributes.source, itemKey: n.attributes.itemKey, paging: n.attributes.paging, pageSize: n.attributes.pageSize })); - return deepFreeze(assertIR({ schema: "burnlist-oven-ir@1", id: ast.attrs.id, version: Number(ast.attrs.version), contract: ast.attrs.contract, theme: ast.attrs.theme, ...(ast.attrs["refresh-seconds"] ? { refreshSeconds: Number(ast.attrs["refresh-seconds"]) } : {}), root, controls, collections, requirements: Object.fromEntries(Object.entries(requirements).map(([k, v]) => [k, [...v].sort()])) })); + return deepFreeze(assertIR({ schema: "burnlist-oven-ir@1", id: ast.attrs.id, version: ast.attrs.version, contract: ast.attrs.contract, theme: ast.attrs.theme, ...(ast.attrs["refresh-seconds"] ? { refreshSeconds: Number(ast.attrs["refresh-seconds"]) } : {}), root, controls, collections, requirements: Object.fromEntries(Object.entries(requirements).map(([k, v]) => [k, [...v].sort()])) })); } function bindingMap(node) { const out = {}; for (const child of node.children) if (child.name === "bind") out[child.attrs.prop] = { source: child.attrs.source, ...(child.attrs.format ? { format: aliases[child.attrs.format] ?? child.attrs.format } : {}), ...(child.attrs.optional ? { optional: child.attrs.optional === "true" } : {}), ...(child.attrs.fallback !== undefined ? { fallback: child.attrs.fallback } : {}) }; return out; } diff --git a/src/ovens/dsl/oven-validate.mjs b/src/ovens/dsl/oven-validate.mjs index b7f7b5a..473244c 100644 --- a/src/ovens/dsl/oven-validate.mjs +++ b/src/ovens/dsl/oven-validate.mjs @@ -4,7 +4,7 @@ import { ELEMENTS, REGISTRY, REQUIRED_PROPS } from "./oven-grammar.mjs"; const idRE = /^[a-z][a-z0-9-]{0,63}$/; const pointer = (value, itemOk) => value === "" || value === "/" || /^\/(?:[^~/]|~[01])*(?:\/(?:[^~/]|~[01])*)*$/.test(value) || (itemOk && /^@item(?:\/(?:[^~/]|~[01])*(?:\/(?:[^~/]|~[01])*)*)?$/.test(value)); const controls = new Set(["mode-toggle", "search", "sort-toggle", "filter-toggle", "domain-tabs"]); -const ints = new Set(["version", "refresh-seconds", "columns", "rows", "row-height", "column", "row", "column-span", "row-span", "page-size", "debounce-ms"]); +const ints = new Set(["refresh-seconds", "columns", "rows", "row-height", "column", "row", "column-span", "row-span", "page-size", "debounce-ms"]); function walk(node, fn, parent = null, itemScope = false, ancestors = []) { fn(node, parent, itemScope, ancestors); for (const child of node.children) walk(child, fn, node, itemScope || node.name === "each" || node.name === "column", [...ancestors, node]); } function add(d, code, msg, node, attr) { d.add(code, msg, { ...(attr ? node.attrSpans[attr] : node.span), path: node.path }); } @@ -31,7 +31,7 @@ export function validateOven(ast, { file = "" } = {}) { if (node.name === "icon" && !REGISTRY.icons.has(a.name)) add(d, "REGISTRY_ICON", `Unknown icon ${a.name}`, node, "name"); if (node.name === "kpi-item" && a.icon && !REGISTRY.icons.has(a.icon)) add(d, "REGISTRY_ICON", `Unknown icon ${a.icon}`, node, "icon"); if (node.name === "kpi-item" && a.variant && !["current", "scenario", "burns", "fields", "frames"].includes(a.variant)) add(d, "SCALAR_VARIANT", "kpi-item variant is not registered", node, "variant"); - if (node.name === "oven") { if (!REGISTRY.themes.has(a.theme)) add(d, "REGISTRY_THEME", `Unknown theme ${a.theme}`, node, "theme"); if (!REGISTRY.contracts.has(a.contract)) add(d, "REGISTRY_CONTRACT", `Unknown contract ${a.contract}`, node, "contract"); if (a.version !== "1") add(d, "SCALAR_VERSION", "Only oven version 1 is supported", node, "version"); } + if (node.name === "oven") { if (!REGISTRY.themes.has(a.theme)) add(d, "REGISTRY_THEME", `Unknown theme ${a.theme}`, node, "theme"); if (!REGISTRY.contracts.has(a.contract)) add(d, "REGISTRY_CONTRACT", `Unknown contract ${a.contract}`, node, "contract"); if (!/^\d+\.\d+\.\d+$/u.test(a.version)) add(d, "SCALAR_VERSION", "Oven version must use major.minor.patch", node, "version"); } if (node.name === "box" && !["div", "section", "main", "span"].includes(a.element)) add(d, "SCALAR_ELEMENT", "box element must be div, section, main, or span", node, "element"); if (node.name === "sort-toggle" && !REGISTRY.sorts.has(a.key)) add(d, "REGISTRY_SORT", `Unknown sort ${a.key}`, node, "key"); if (node.name === "filter-toggle" && !REGISTRY.filters.has(a.key)) add(d, "REGISTRY_FILTER", `Unknown filter ${a.key}`, node, "key"); diff --git a/src/ovens/dsl/oven-validate.test.mjs b/src/ovens/dsl/oven-validate.test.mjs index 7077991..0c0b598 100644 --- a/src/ovens/dsl/oven-validate.test.mjs +++ b/src/ovens/dsl/oven-validate.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { compileOven } from "./oven-compile.mjs"; const invalid = (xml, code) => { const r = compileOven(xml); assert.equal(r.ok, false); assert.ok(r.diagnostics.some((x) => x.code === code), JSON.stringify(r.diagnostics)); }; -const root = (body, attrs = "") => `${body}`; +const root = (body, attrs = "") => `${body}`; test("vocabulary, scalar, registry and references are closed", () => { invalid(root(""), "GRAMMAR_ELEMENT"); invalid(root(""), "GRAMMAR_ATTRIBUTE"); @@ -14,7 +14,7 @@ test("vocabulary, scalar, registry and references are closed", () => { invalid(root(""), "REGISTRY_ICON"); invalid(root(""), "REGISTRY_FILTER"); invalid(root(""), "REGISTRY_SORT"); - invalid('', "REGISTRY_THEME"); + invalid('', "REGISTRY_THEME"); }); test("structure and interaction validation", () => { invalid(root(""), "STRUCTURE_GRID_OVERLAP"); diff --git a/src/ovens/oven-contract.mjs b/src/ovens/oven-contract.mjs index 353f33c..11b69f8 100644 --- a/src/ovens/oven-contract.mjs +++ b/src/ovens/oven-contract.mjs @@ -122,7 +122,11 @@ export function normalizeOvenPackage(value) { if (compiled.ir.id !== id) { throw new Error(`Oven ${id} .oven root id "${compiled.ir.id}" must match the package id.`); } - return { id, instructions, oven }; + return { id, version: compiled.ir.version, instructions, oven }; +} + +export function ovenIdentity(pkg) { + return `${pkg.id}@${pkg.version}`; } function canonicalJson(value) { diff --git a/src/ovens/oven-contract.test.mjs b/src/ovens/oven-contract.test.mjs index a811c3c..840d019 100644 --- a/src/ovens/oven-contract.test.mjs +++ b/src/ovens/oven-contract.test.mjs @@ -4,6 +4,7 @@ import { legacyOvenRevision, normalizeOvenDetail, normalizeOvenPackage, + ovenIdentity, ovenRevision, } from "./oven-contract.mjs"; @@ -11,7 +12,7 @@ function packageFixture(id = "sample-oven") { return { id, instructions: "# Sample Oven\n\nFollow the checklist.\n", - oven: `\n \n\n`, + oven: `\n \n\n`, }; } @@ -48,6 +49,14 @@ test("id-is-part-of-revision", () => { assert.notEqual(ovenRevision(normalizedFixture("original-oven")), ovenRevision(normalizedFixture("forked-oven"))); }); +test("normalized packages expose a semver identity distinct from their revision", () => { + const pkg = normalizedFixture("sample-oven"); + assert.equal(pkg.version, "0.1.0"); + assert.equal(ovenIdentity(pkg), "sample-oven@0.1.0"); + assert.match(ovenRevision(pkg), /^o1-sha256:/u); + assert.notEqual(ovenIdentity(pkg), ovenRevision(pkg)); +}); + test("normalizeOvenPackage rejects a root id that differs from the package id", () => { assert.throws( () => normalizeOvenPackage({ ...packageFixture("other-oven"), oven: packageFixture("sample-oven").oven }), diff --git a/src/ovens/oven-starter.mjs b/src/ovens/oven-starter.mjs index 49823bc..329b63d 100644 --- a/src/ovens/oven-starter.mjs +++ b/src/ovens/oven-starter.mjs @@ -7,7 +7,7 @@ function escapeXmlAttribute(value) { } export function starterOvenSource(id, name) { - return ` + return ` `; diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 23bcaae..cf3cf34 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -551,6 +551,7 @@ function findOven(id, selectedKey = null) { function ovenSummary(oven) { return { id: oven.id, + version: oven.ir.version, name: oven.name, description: oven.description, builtIn: oven.builtIn, diff --git a/src/server/custom-oven-index.test.mjs b/src/server/custom-oven-index.test.mjs index 6dba932..f9210c5 100644 --- a/src/server/custom-oven-index.test.mjs +++ b/src/server/custom-oven-index.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { httpGet, withServer } from "./dashboard-routes-fixtures.mjs"; -const ovenSource = ` +const ovenSource = ` diff --git a/src/server/custom-oven-view-routes.test.mjs b/src/server/custom-oven-view-routes.test.mjs index d6fca73..89678a2 100644 --- a/src/server/custom-oven-view-routes.test.mjs +++ b/src/server/custom-oven-view-routes.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { httpGet, withServer } from "./dashboard-routes-fixtures.mjs"; -const ovenSource = ` +const ovenSource = ` diff --git a/src/server/oven-routes.test.mjs b/src/server/oven-routes.test.mjs index 79043ac..46d91d2 100644 --- a/src/server/oven-routes.test.mjs +++ b/src/server/oven-routes.test.mjs @@ -178,7 +178,7 @@ test("custom Ovens are identified by repository while built-ins remain global", assert.equal(new Set(shared.map((oven) => oven.repoKey)).size, 2); assert.deepEqual( Object.keys(shared[0]).sort(), - ["builtIn", "description", "id", "name", "ovenRevision", "repoKey"], + ["builtIn", "description", "id", "name", "ovenRevision", "repoKey", "version"], ); assert.equal(catalog.ovens.find((oven) => oven.id === "checklist").repoKey, null); assert.equal(catalog.ovens.find((oven) => oven.id === "differential-testing").repoKey, null); diff --git a/website/src/content/docs/ovens/authoring.mdx b/website/src/content/docs/ovens/authoring.mdx index 467b680..dd3566c 100644 --- a/website/src/content/docs/ovens/authoring.mdx +++ b/website/src/content/docs/ovens/authoring.mdx @@ -60,7 +60,7 @@ JSON document; it cannot execute code or transform project data. Here is a complete generic `deploy-status` Oven, `kpi.oven`: ```xml - + @@ -91,7 +91,7 @@ fallback values. The small Streaming Diff illustration uses its matching built-in pair: ```xml - diff --git a/website/src/content/docs/ovens/dsl-reference.mdx b/website/src/content/docs/ovens/dsl-reference.mdx index 7675e15..489ad0f 100644 --- a/website/src/content/docs/ovens/dsl-reference.mdx +++ b/website/src/content/docs/ovens/dsl-reference.mdx @@ -3,7 +3,7 @@ title: .oven DSL reference description: The declarative vocabulary, bindings, controls, and registries for .oven files. --- -`.oven` is a closed, XML-like DSL. The root is ``; `refresh-seconds` is an optional positive integer refresh interval. Only registered elements, attributes, and registry values compile. +`.oven` is a closed, XML-like DSL. The root is ``; `refresh-seconds` is an optional positive integer refresh interval. Only registered elements, attributes, and registry values compile. ## Layout diff --git a/website/src/content/docs/ovens/oven-system.mdx b/website/src/content/docs/ovens/oven-system.mdx index 8d02789..2936961 100644 --- a/website/src/content/docs/ovens/oven-system.mdx +++ b/website/src/content/docs/ovens/oven-system.mdx @@ -7,7 +7,7 @@ A `.oven` file is declarative, non-executable detail-page data. Its XML-like ` From 240e3cddee01ec84ad97a47f5f3e65f1ca673fd1 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 20:10:33 +0200 Subject: [PATCH 02/21] feat: vendor per-project oven pins with opt-in upgrade (260721-001) --- scripts/verify-test-files.mjs | 3 + src/cli/oven-cli.mjs | 30 +++- src/cli/oven-vendor-cli.test.mjs | 154 ++++++++++++++++++ src/server/burnlist-dashboard-server.mjs | 52 ++++++- src/server/oven-vendor-resolution.test.mjs | 31 ++++ src/server/oven-vendor.mjs | 136 ++++++++++++++++ src/server/oven-vendor.test.mjs | 172 +++++++++++++++++++++ 7 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 src/cli/oven-vendor-cli.test.mjs create mode 100644 src/server/oven-vendor-resolution.test.mjs create mode 100644 src/server/oven-vendor.mjs create mode 100644 src/server/oven-vendor.test.mjs diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index c24043e..777280d 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -55,11 +55,14 @@ export const verificationTestFiles = [ "src/cli/oven-fork-id.test.mjs", "src/cli/oven-cli-stdout.test.mjs", "src/cli/oven-storage.test.mjs", + "src/cli/oven-vendor-cli.test.mjs", "src/cli/umbrella.test.mjs", "src/cli/hooks-config.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", "src/server/oven-bindings.test.mjs", "src/server/oven-warm.test.mjs", + "src/server/oven-vendor.test.mjs", + "src/server/oven-vendor-resolution.test.mjs", "src/server/registry.test.mjs", "src/server/dir-lock.test.mjs", "src/server/repo-map.test.mjs", diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index 787f8fa..aca97de 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -7,6 +7,7 @@ // own file plumbing so it never has to import the dashboard server (which // boots an HTTP listener on import). Like the dashboard, it can only create or // replace custom Ovens under ignored local state; it never executes anything. +import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; @@ -14,6 +15,7 @@ import { publishOvenEvent } from "../events/oven-event-store.mjs"; import { scanXml } from "../ovens/dsl/xml-scan.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; import { resolveCustomOvensDir } from "../server/oven-storage.mjs"; +import { readVendoredOven, vendoredOvenPath, writeVendoredOven } from "../server/oven-vendor.mjs"; import { renderOvenTree, sourceTable } from "./oven-cli-render.mjs"; import { createOvenCatalog, persistOven, resolvePackageInput } from "./oven-storage.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; @@ -141,6 +143,8 @@ Usage: burnlist oven create --package (JSON: {name?, instructions, oven}) burnlist oven update [same inputs as create] burnlist oven fork + burnlist oven adopt [--repo ] [--force] + burnlist oven upgrade [--repo ] Options: --name Set the Oven name (owns the level-one heading). @@ -157,7 +161,7 @@ Options: --payload Optional compact JSON event payload. --ovens-dir

Custom Oven storage (default .local/burnlist/ovens). --unsafe-ovens-dir Permit --ovens-dir outside repo-local state. - --force On create, replace an existing custom Oven. + --force On create or adopt, replace an existing Oven. --json Machine-readable output for list/view. Custom Ovens live under ignored local state and only affect future Runs. @@ -313,6 +317,30 @@ try { return; } + if (subcommand === "adopt" || subcommand === "upgrade") { + const id = positionals[0]; + if (!id) fail(`Usage: burnlist oven ${subcommand} [--repo ]${subcommand === "adopt" ? " [--force]" : ""}`); + const shipped = readOvenDir(builtInOvensDir, id, true); + if (!shipped) fail(`Oven ${id} is not a shipped built-in.`); + const shippedInstructions = readFileSync(join(builtInOvensDir, shipped.id, "instructions.md"), "utf8"); + const shippedOven = readFileSync(join(builtInOvensDir, shipped.id, `${shipped.id}.oven`), "utf8"); + const targetRoot = repoRoot(); + const targetPath = vendoredOvenPath(targetRoot, id); + if (subcommand === "adopt") { + if (existsSync(targetPath) && !flags.has("force")) fail(`Oven ${id} is already vendored at ${targetPath}.`); + } else if (!readVendoredOven(targetRoot, id)) { + fail(`Oven ${id} is not adopted; run \`oven adopt ${id}\` first.`); + } + const saved = writeVendoredOven(targetRoot, { + id, + instructions: shippedInstructions, + oven: shippedOven, + }); + if (subcommand === "adopt") console.log(`Adopted Oven ${saved.id}@${saved.version} at ${targetPath}`); + else console.log(`Upgraded Oven ${saved.id}@${saved.version} at ${targetPath}\nrevision: ${saved.revision}`); + return; + } + fail(`Unknown subcommand "${subcommand}". Run \`burnlist oven help\`.`); } catch (error) { fail(error.message); diff --git a/src/cli/oven-vendor-cli.test.mjs b/src/cli/oven-vendor-cli.test.mjs new file mode 100644 index 0000000..45958f6 --- /dev/null +++ b/src/cli/oven-vendor-cli.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { normalizeOvenPackage, ovenRevision } from "../ovens/oven-contract.mjs"; + +const packageRoot = resolve(new URL("../..", import.meta.url).pathname); +const binPath = join(packageRoot, "bin", "burnlist.mjs"); +const builtInId = "checklist"; +const builtInPath = join(packageRoot, "ovens", builtInId); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "burnlist-oven-vendor-cli-")); + const repo = join(root, "repo"); + mkdirSync(repo); + execFileSync("git", ["init", "-q"], { cwd: repo }); + return { repo, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +function runResult(context, ...args) { + return spawnSync(process.execPath, [binPath, ...args], { cwd: context.repo, encoding: "utf8" }); +} + +function run(context, ...args) { + const result = runResult(context, ...args); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result.stdout; +} + +function shippedSource() { + return { + id: builtInId, + instructions: readFileSync(join(builtInPath, "instructions.md"), "utf8"), + oven: readFileSync(join(builtInPath, `${builtInId}.oven`), "utf8"), + }; +} + +function vendoredPath(context) { + return join(context.repo, ".burnlist", "ovens", builtInId); +} + +function readPin(context) { + return JSON.parse(readFileSync(join(vendoredPath(context), "pin.json"), "utf8")); +} + +function assertValidPin(pin, pkg) { + const normalized = normalizeOvenPackage(pkg); + assert.deepEqual(Object.keys(pin).sort(), ["id", "pinnedAt", "revision", "source", "version"]); + assert.equal(pin.id, builtInId); + assert.equal(pin.version, normalized.version); + assert.equal(pin.revision, ovenRevision(pkg)); + assert.equal(pin.source, "built-in"); + assert.equal(new Date(pin.pinnedAt).toISOString(), pin.pinnedAt); +} + +test("oven adopt copies a shipped built-in into a repo with a valid pin", () => { + const context = fixture(); + const shipped = shippedSource(); + try { + const output = run(context, "oven", "adopt", builtInId, "--repo", context.repo); + const directory = vendoredPath(context); + const pin = readPin(context); + assert.deepEqual(readdirSync(directory).sort(), [`${builtInId}.oven`, "instructions.md", "pin.json"]); + assert.equal(readFileSync(join(directory, "instructions.md"), "utf8"), shipped.instructions); + assert.equal(readFileSync(join(directory, `${builtInId}.oven`), "utf8"), shipped.oven); + assertValidPin(pin, shipped); + assert.match(output, /Adopted Oven checklist/u); + assert.ok(output.includes(directory)); + assert.ok(output.includes(`${builtInId}@${pin.version}`)); + } finally { context.cleanup(); } +}); + +test("oven adopt rejects unknown and existing pins unless forced", () => { + const context = fixture(); + try { + const unknown = runResult(context, "oven", "adopt", "not-shipped", "--repo", context.repo); + assert.notEqual(unknown.status, 0); + assert.match(unknown.stderr, /(?:not|unknown).{0,40}shipped built-in/iu); + + run(context, "oven", "adopt", builtInId, "--repo", context.repo); + const duplicate = runResult(context, "oven", "adopt", builtInId, "--repo", context.repo); + assert.notEqual(duplicate.status, 0); + assert.match(duplicate.stderr, /already (?:vendored|adopted)/iu); + assert.match(run(context, "oven", "adopt", builtInId, "--repo", context.repo, "--force"), /Adopted Oven checklist/u); + } finally { context.cleanup(); } +}); + +test("an adopted package is a complete standalone byte copy", () => { + const context = fixture(); + const shipped = shippedSource(); + try { + run(context, "oven", "adopt", builtInId, "--repo", context.repo); + const directory = vendoredPath(context); + const ovenBytes = readFileSync(join(directory, `${builtInId}.oven`)); + const instructionsBytes = readFileSync(join(directory, "instructions.md")); + const pinBytes = readFileSync(join(directory, "pin.json")); + assert.deepEqual(ovenBytes, readFileSync(join(builtInPath, `${builtInId}.oven`))); + assert.deepEqual(instructionsBytes, readFileSync(join(builtInPath, "instructions.md"))); + assertValidPin(JSON.parse(pinBytes), shipped); + const directoryStat = lstatSync(directory); + assert.equal(directoryStat.isDirectory(), true); + assert.equal(directoryStat.isSymbolicLink(), false); + for (const name of [`${builtInId}.oven`, "instructions.md", "pin.json"]) { + const stat = lstatSync(join(directory, name)); + assert.equal(stat.isFile(), true); + assert.equal(stat.isSymbolicLink(), false); + } + assert.deepEqual(readFileSync(join(directory, `${builtInId}.oven`)), ovenBytes); + assert.deepEqual(readFileSync(join(directory, "pin.json")), pinBytes); + } finally { context.cleanup(); } +}); + +test("oven upgrade is opt-in and re-copies the shipped source after adoption", async () => { + const context = fixture(); + const shipped = shippedSource(); + try { + const missing = runResult(context, "oven", "upgrade", builtInId, "--repo", context.repo); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /(?:not adopted|adopt.{0,20}first)/iu); + + run(context, "oven", "adopt", builtInId, "--repo", context.repo); + const before = readPin(context); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); + const output = run(context, "oven", "upgrade", builtInId, "--repo", context.repo); + const after = readPin(context); + assertValidPin(after, shipped); + assert.notEqual(after.pinnedAt, before.pinnedAt); + assert.equal(readFileSync(join(vendoredPath(context), "instructions.md"), "utf8"), shipped.instructions); + assert.equal(readFileSync(join(vendoredPath(context), `${builtInId}.oven`), "utf8"), shipped.oven); + assert.match(output, /Upgraded Oven checklist/u); + assert.ok(output.includes(vendoredPath(context))); + assert.ok(output.includes(`${builtInId}@${after.version}`)); + assert.ok(output.includes(after.revision)); + } finally { context.cleanup(); } +}); + +test("adopted oven files are not ignored by Git", () => { + const context = fixture(); + try { + run(context, "oven", "adopt", builtInId, "--repo", context.repo); + const pinPath = join(".burnlist", "ovens", builtInId, "pin.json"); + const ignored = spawnSync("git", ["check-ignore", "--", pinPath], { cwd: context.repo, encoding: "utf8" }); + assert.equal(ignored.status, 1, `${ignored.stdout}${ignored.stderr}`); + } finally { context.cleanup(); } +}); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index cf3cf34..6ff0e30 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -51,6 +51,7 @@ import { serializeOvenPackage, } from "./oven-storage.mjs"; import { warmOvenHandler } from "./oven-warm.mjs"; +import { readVendoredOven, vendoredOvenPath, vendoredOvensDir } from "./oven-vendor.mjs"; import { LIFECYCLES, burnlistIdForPlan, @@ -491,6 +492,45 @@ function readOven(root, id, builtIn, customRepoRoot = umbrellaRoot) { } } +function readVendoredOvenForRepo(repoRoot, id) { + const path = vendoredOvenPath(repoRoot, id); + if (!safeStat(path)?.isDirectory()) return null; + const ovenPackage = readVendoredOven(repoRoot, id); + if (!ovenPackage) return null; + return { + id: ovenPackage.id, + name: instructionsName(ovenPackage.instructions, ovenPackage.id), + description: instructionsDescription(ovenPackage.instructions), + builtIn: true, + instructions: ovenPackage.instructions, + oven: ovenPackage.oven, + ir: compileOven(ovenPackage.oven).ir, + ovenRevision: ovenPackage.revision, + }; +} + +function vendoredOvensIn(repoRoot) { + let entries; + try { + entries = readdirSync(vendoredOvensDir(repoRoot), { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + return entries + .map((entry) => entry.name) + .filter((id) => !id.startsWith(".") && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) + .map((id) => { + try { + return readVendoredOvenForRepo(repoRoot, id); + } catch (error) { + console.warn(`Ignoring malformed vendored Oven ${id}: ${error.message}`); + return null; + } + }) + .filter(Boolean); +} + function ovensIn(root, builtIn, customRepoRoot = umbrellaRoot) { if (!builtIn) assertCustomOvensDir(customRepoRoot, root, { unsafe: unsafeOvensDir }); let entries; @@ -517,6 +557,9 @@ function ovensIn(root, builtIn, customRepoRoot = umbrellaRoot) { function discoverOvens() { const ovens = ovensIn(builtInOvensDir, true).map((oven) => ({ ...oven, repoKey: null, repoRoot: null })); for (const repo of ovenScopeRepos()) { + for (const oven of vendoredOvensIn(repo.root)) { + ovens.push({ ...oven, repoKey: repo.repoKey, repoRoot: repo.root }); + } const customOvensDir = customOvensDirFor(repo.root); for (const oven of ovensIn(customOvensDir, false, repo.root)) { ovens.push({ ...oven, repoKey: repo.repoKey, repoRoot: repo.root }); @@ -538,8 +581,13 @@ function selectedRepoKey(url) { function findOven(id, selectedKey = null) { const safeId = ovenId(id); - // Built-ins are global: resolve them by id regardless of repoKey (repoKey only selects a repo's - // DATA binding, not the oven's identity). Custom ovens are identified by (repoKey, id). + if (selectedKey !== null) { + const repo = ovenScopeRepos().find((entry) => entry.repoKey === selectedKey); + const vendored = repo ? readVendoredOvenForRepo(repo.root, safeId) : null; + if (vendored) return { ...vendored, repoKey: repo.repoKey, repoRoot: repo.root }; + } + // Built-ins are global: resolve them by id regardless of repoKey when no repo has a pin. + // Custom ovens are identified by (repoKey, id). const builtin = readOven(builtInOvensDir, safeId, true); if (builtin) return { ...builtin, repoKey: null, repoRoot: null }; if (selectedKey === null) return null; diff --git a/src/server/oven-vendor-resolution.test.mjs b/src/server/oven-vendor-resolution.test.mjs new file mode 100644 index 0000000..991dc54 --- /dev/null +++ b/src/server/oven-vendor-resolution.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import test from "node:test"; +import { writeVendoredOven } from "./oven-vendor.mjs"; +import { httpGet, withServer } from "./dashboard-routes-fixtures.mjs"; + +const id = "checklist"; +const instructions = "# Vendored Checklist\n\nUse the repository copy.\n"; +const oven = ` + + +`; + +test("a repo vendored Oven is served ahead of the shipped built-in", { timeout: 20_000 }, async () => { + await withServer({ + withBurnlist: true, + setup: async ({ fixtureRoot }) => { + writeVendoredOven(join(fixtureRoot, "fixture-repo"), { id, instructions, oven }); + }, + }, async ({ baseUrl }) => { + const catalog = JSON.parse((await httpGet(baseUrl, "/api/ovens")).body).ovens; + const vendored = catalog.find((entry) => entry.id === id && entry.repoKey !== null); + assert.ok(vendored); + const response = await httpGet(baseUrl, `/api/ovens/${id}?repoKey=${vendored.repoKey}`); + assert.equal(response.status, 200); + const served = JSON.parse(response.body).oven; + assert.equal(served.instructions, instructions); + assert.equal(served.oven, oven); + assert.equal(served.ir.version, "7.8.9"); + }); +}); diff --git a/src/server/oven-vendor.mjs b/src/server/oven-vendor.mjs new file mode 100644 index 0000000..2999820 --- /dev/null +++ b/src/server/oven-vendor.mjs @@ -0,0 +1,136 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { ovenId, normalizeOvenPackage, ovenRevision } from "../ovens/oven-contract.mjs"; + +function isWithin(parent, child) { + const pathFromParent = relative(parent, child); + return pathFromParent === "" + || (pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent)); +} + +function nearestRealPath(path) { + const suffix = []; + let current = resolve(path); + while (true) { + try { + return resolve(realpathSync(current), ...suffix.reverse()); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + const parent = dirname(current); + if (parent === current) throw error; + suffix.push(basename(current)); + current = parent; + } + } +} + +function assertVendoredOvenPath(repoRoot, id) { + const root = vendoredOvensDir(repoRoot); + const path = vendoredOvenPath(repoRoot, id); + const repo = nearestRealPath(resolve(repoRoot)); + const realRoot = nearestRealPath(root); + const realPath = nearestRealPath(path); + if (!isWithin(resolve(root), resolve(path)) || !isWithin(repo, realRoot) || !isWithin(realRoot, realPath)) { + throw new Error(`Vendored Oven path escapes ${root}: ${path}`); + } + return path; +} + +function readFileIfPresent(path) { + try { + return readFileSync(path, "utf8"); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null; + throw error; + } +} + +function validatePin(pin, pkg) { + const expectedKeys = ["id", "version", "revision", "source", "pinnedAt"]; + if (!pin || typeof pin !== "object" || Array.isArray(pin) + || Object.keys(pin).length !== expectedKeys.length + || expectedKeys.some((key) => !Object.hasOwn(pin, key))) { + throw new Error("Vendored Oven pin is invalid."); + } + const revision = ovenRevision(pkg); + if (pin.id !== pkg.id || pin.version !== pkg.version || pin.revision !== revision + || typeof pin.source !== "string" || !pin.source + || typeof pin.pinnedAt !== "string" || new Date(pin.pinnedAt).toISOString() !== pin.pinnedAt) { + throw new Error(`Vendored Oven ${pkg.id} pin does not match its source.`); + } + return pin; +} + +function readOvenPackageDir(root, id) { + const safeId = ovenId(id); + const directory = join(root, safeId); + const instructions = readFileIfPresent(join(directory, "instructions.md")); + const oven = readFileIfPresent(join(directory, `${safeId}.oven`)); + if (instructions === null || oven === null) return null; + const pkg = normalizeOvenPackage({ id: safeId, instructions, oven }); + return { id: safeId, instructions, oven, version: pkg.version, revision: ovenRevision({ id: safeId, instructions, oven }) }; +} + +export function vendoredOvensDir(repoRoot) { + return join(resolve(repoRoot), ".burnlist", "ovens"); +} + +export function vendoredOvenPath(repoRoot, id) { + return join(vendoredOvensDir(repoRoot), ovenId(id)); +} + +export function readVendoredOven(repoRoot, id) { + const safeId = ovenId(id); + const directory = assertVendoredOvenPath(repoRoot, safeId); + const instructions = readFileIfPresent(join(directory, "instructions.md")); + const oven = readFileIfPresent(join(directory, `${safeId}.oven`)); + const pinText = readFileIfPresent(join(directory, "pin.json")); + if (instructions === null || oven === null || pinText === null) return null; + let pin; + try { + pin = JSON.parse(pinText); + } catch (error) { + throw new Error(`Vendored Oven ${safeId} pin is invalid: ${error.message}`); + } + const normalized = normalizeOvenPackage({ id: safeId, instructions, oven }); + const pkg = { id: safeId, instructions, oven, version: normalized.version }; + const revision = ovenRevision(pkg); + return { ...pkg, revision, pin: validatePin(pin, pkg) }; +} + +export function writeVendoredOven(repoRoot, { id, instructions, oven, source = "built-in", now } = {}) { + const safeId = ovenId(id); + const normalized = normalizeOvenPackage({ id: safeId, instructions, oven }); + if (typeof source !== "string" || !source) throw new Error("Vendored Oven source must be a non-empty string."); + const pkg = { id: safeId, instructions, oven, version: normalized.version }; + const revision = ovenRevision(pkg); + const pin = { + id: safeId, + version: normalized.version, + revision, + source, + pinnedAt: (now ?? new Date()).toISOString(), + }; + const directory = assertVendoredOvenPath(repoRoot, safeId); + const root = vendoredOvensDir(repoRoot); + let temporary; + try { + mkdirSync(root, { recursive: true }); + temporary = mkdtempSync(join(root, `${safeId}.tmp-`)); + writeFileSync(join(temporary, "instructions.md"), instructions); + writeFileSync(join(temporary, `${safeId}.oven`), oven); + writeFileSync(join(temporary, "pin.json"), `${JSON.stringify(pin, null, 2)}\n`); + rmSync(directory, { recursive: true, force: true }); + renameSync(temporary, directory); + temporary = null; + } finally { + if (temporary) rmSync(temporary, { recursive: true, force: true }); + } + return { ...pkg, revision, pin }; +} + +export function resolveOvenForRepo({ repoRoot, builtInOvensDir, customOvensDir, id } = {}) { + const vendored = readVendoredOven(repoRoot, id); + if (vendored) return vendored; + return readOvenPackageDir(builtInOvensDir, id) ?? readOvenPackageDir(customOvensDir, id); +} diff --git a/src/server/oven-vendor.test.mjs b/src/server/oven-vendor.test.mjs new file mode 100644 index 0000000..604d633 --- /dev/null +++ b/src/server/oven-vendor.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { ovenRevision } from "../ovens/oven-contract.mjs"; +import { + readVendoredOven, + resolveOvenForRepo, + vendoredOvenPath, + vendoredOvensDir, + writeVendoredOven, +} from "./oven-vendor.mjs"; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "burnlist-oven-vendor-")); + const repoRoot = join(root, "repo"); + const builtInOvensDir = join(root, "built-ins"); + const customOvensDir = join(root, "custom"); + mkdirSync(repoRoot); + mkdirSync(builtInOvensDir); + mkdirSync(customOvensDir); + return { + repoRoot, + builtInOvensDir, + customOvensDir, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +function source(id, version, marker) { + return { + id, + instructions: `# ${marker}\n\nInstructions for ${marker}.\n`, + oven: `\n \n\n`, + }; +} + +function writeSource(root, pkg) { + const directory = join(root, pkg.id); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "instructions.md"), pkg.instructions); + writeFileSync(join(directory, `${pkg.id}.oven`), pkg.oven); +} + +function assertIso8601(value) { + assert.equal(typeof value, "string"); + assert.equal(new Date(value).toISOString(), value); +} + +test("writeVendoredOven writes byte copies and an exact content pin", () => { + const context = fixture(); + const pkg = source("sample-oven", "1.0.0", "Pinned source"); + try { + writeVendoredOven(context.repoRoot, pkg); + const expectedPath = join(context.repoRoot, ".burnlist", "ovens", pkg.id); + assert.equal(vendoredOvensDir(context.repoRoot), join(context.repoRoot, ".burnlist", "ovens")); + assert.equal(vendoredOvenPath(context.repoRoot, pkg.id), expectedPath); + assert.deepEqual(readdirSync(expectedPath).sort(), ["instructions.md", "pin.json", `${pkg.id}.oven`]); + assert.equal(readFileSync(join(expectedPath, "instructions.md"), "utf8"), pkg.instructions); + assert.equal(readFileSync(join(expectedPath, `${pkg.id}.oven`), "utf8"), pkg.oven); + + const pin = JSON.parse(readFileSync(join(expectedPath, "pin.json"), "utf8")); + assert.deepEqual(Object.keys(pin).sort(), ["id", "pinnedAt", "revision", "source", "version"]); + assert.equal(pin.id, pkg.id); + assert.equal(pin.version, "1.0.0"); + assert.equal(pin.revision, ovenRevision(pkg)); + assert.equal(pin.source, "built-in"); + assertIso8601(pin.pinnedAt); + } finally { context.cleanup(); } +}); + +test("readVendoredOven round-trips a pin and returns null for absent or incomplete packages", () => { + const context = fixture(); + const pkg = source("sample-oven", "1.2.3", "Round trip"); + try { + assert.equal(readVendoredOven(context.repoRoot, pkg.id), null); + mkdirSync(vendoredOvenPath(context.repoRoot, "incomplete"), { recursive: true }); + writeFileSync(join(vendoredOvenPath(context.repoRoot, "incomplete"), "instructions.md"), "# Incomplete\n"); + assert.equal(readVendoredOven(context.repoRoot, "incomplete"), null); + + writeVendoredOven(context.repoRoot, pkg); + const saved = readVendoredOven(context.repoRoot, pkg.id); + assert.equal(saved.id, pkg.id); + assert.equal(saved.version, "1.2.3"); + assert.equal(saved.instructions, pkg.instructions); + assert.equal(saved.oven, pkg.oven); + assert.equal(saved.revision, ovenRevision(pkg)); + assert.deepEqual(saved.pin, JSON.parse(readFileSync(join(vendoredOvenPath(context.repoRoot, pkg.id), "pin.json"), "utf8"))); + } finally { context.cleanup(); } +}); + +test("resolveOvenForRepo prefers vendored, then shipped built-in, then custom", () => { + const context = fixture(); + const vendored = source("sample-oven", "1.0.0", "Vendored"); + const builtIn = source("sample-oven", "2.0.0", "Built in"); + const custom = source("sample-oven", "3.0.0", "Custom"); + const options = { + repoRoot: context.repoRoot, + builtInOvensDir: context.builtInOvensDir, + customOvensDir: context.customOvensDir, + id: vendored.id, + }; + try { + writeSource(context.builtInOvensDir, builtIn); + writeSource(context.customOvensDir, custom); + writeVendoredOven(context.repoRoot, vendored); + assert.equal(resolveOvenForRepo(options).oven, vendored.oven); + assert.equal(resolveOvenForRepo(options).revision, ovenRevision(vendored)); + + rmSync(vendoredOvenPath(context.repoRoot, vendored.id), { recursive: true }); + assert.equal(resolveOvenForRepo(options).oven, builtIn.oven); + rmSync(join(context.builtInOvensDir, builtIn.id), { recursive: true }); + assert.equal(resolveOvenForRepo(options).oven, custom.oven); + } finally { context.cleanup(); } +}); + +test("a shipped source change cannot move a repo pin without an explicit vendored write", () => { + const context = fixture(); + const initial = source("sample-oven", "1.0.0", "Initial"); + const newer = source("sample-oven", "2.0.0", "Newer"); + const options = { + repoRoot: context.repoRoot, + builtInOvensDir: context.builtInOvensDir, + customOvensDir: context.customOvensDir, + id: initial.id, + }; + try { + writeSource(context.builtInOvensDir, initial); + writeVendoredOven(context.repoRoot, initial); + const initialPin = readVendoredOven(context.repoRoot, initial.id).pin; + + writeFileSync(join(context.builtInOvensDir, initial.id, `${initial.id}.oven`), newer.oven); + writeFileSync(join(context.builtInOvensDir, initial.id, "instructions.md"), newer.instructions); + const stillPinned = resolveOvenForRepo(options); + assert.equal(stillPinned.oven, initial.oven); + assert.equal(stillPinned.revision, ovenRevision(initial)); + assert.deepEqual(readVendoredOven(context.repoRoot, initial.id).pin, initialPin); + + writeVendoredOven(context.repoRoot, newer); + const upgraded = resolveOvenForRepo(options); + assert.equal(upgraded.oven, newer.oven); + assert.equal(upgraded.revision, ovenRevision(newer)); + assert.notEqual(upgraded.revision, initialPin.revision); + assert.equal(upgraded.pin.version, "2.0.0"); + } finally { context.cleanup(); } +}); + +test("vendored replacement is all-or-nothing and leaves a plain committed directory", () => { + const context = fixture(); + const initial = source("sample-oven", "1.0.0", "Stable"); + try { + writeVendoredOven(context.repoRoot, initial); + const directory = vendoredOvenPath(context.repoRoot, initial.id); + const before = Object.fromEntries(readdirSync(directory).map((name) => [name, readFileSync(join(directory, name))])); + assert.throws(() => writeVendoredOven(context.repoRoot, { ...initial, oven: "" })); + const after = Object.fromEntries(readdirSync(directory).map((name) => [name, readFileSync(join(directory, name))])); + assert.deepEqual(after, before); + assert.deepEqual(readdirSync(vendoredOvensDir(context.repoRoot)), [initial.id]); + const stat = lstatSync(directory); + assert.equal(stat.isDirectory(), true); + assert.equal(stat.isSymbolicLink(), false); + } finally { context.cleanup(); } +}); From 60b4010e937ec61b99bedb119764ffe94416acb1 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 20:34:16 +0200 Subject: [PATCH 03/21] feat: path-based dashboard URL scheme with repoKey in path and back-compat redirects (260721-001) --- dashboard/src/App.tsx | 4 +- .../StreamingDiff/StreamingDiff.tsx | 2 +- .../streaming-diff-dom-golden.test.mjs | 2 +- .../streaming-diff-dom.golden.html | 2 +- dashboard/src/lib/hrefs.ts | 60 ++++------- dashboard/src/lib/index.ts | 1 + dashboard/src/lib/route-model.mjs | 99 +++++++++++++++++++ dashboard/src/lib/route-model.test.mjs | 56 +++++++++++ .../src/lib/streaming-diff-oven-adapter.ts | 2 +- dashboard/src/lib/streaming-diff.mjs | 4 +- dashboard/src/lib/streaming-diff.test.mjs | 2 +- dashboard/src/main.tsx | 4 + dashboard/src/oven/FeedList/FeedList.test.ts | 8 +- .../StreamingDiffHeading.test.ts | 2 +- .../differential-testing-renderer.js | 4 +- .../src/oven/runtime/oven-live-data.test.ts | 12 +++ dashboard/src/oven/runtime/oven-live-data.ts | 14 ++- ovens/differential-testing/engine/handler.mjs | 6 +- .../engine/transport-poll.test.mjs | 4 +- .../engine/transport-renderer.test.mjs | 2 +- ovens/model-lab/engine/model-lab-handler.mjs | 4 +- ovens/visual-parity/handler.mjs | 4 +- scripts/verify-test-files.mjs | 1 + skills/burnlist/references/oven-authoring.md | 2 +- src/cli/streaming-diff-cli.mjs | 2 +- src/cli/streaming-diff-cli.test.mjs | 2 +- src/server/burnlist-dashboard-server.mjs | 12 ++- src/server/custom-oven-index.test.mjs | 4 +- src/server/custom-oven-view-routes.test.mjs | 2 +- src/server/dashboard-routes.test.mjs | 12 +++ src/server/oven-routes.test.mjs | 4 +- website/src/content/docs/ovens/authoring.mdx | 2 +- 32 files changed, 262 insertions(+), 79 deletions(-) create mode 100644 dashboard/src/lib/route-model.mjs create mode 100644 dashboard/src/lib/route-model.test.mjs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 2d84fd1..7b8f845 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -9,7 +9,7 @@ export function App() { const section = currentSection(); const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search]); const [filter, setFilter] = useState(() => filterFromUrl(FILTERS)); - const dashboardSection = section === "streaming-diff" ? "burnlists" : section; + const dashboardSection = ["landing", "burnlist", "streaming-diff"].includes(section) ? "burnlists" : section; const { projects, progress, error, loading } = useDashboardData({ section: dashboardSection, selected }); const visibleBurnlistCount = projects.reduce((total, project) => total + project.entries.filter((entry) => filter === "all" || entry.status === filter).length, 0); const visibleProjectCount = projects.filter((project) => project.entries.some((entry) => filter === "all" || entry.status === filter)).length; @@ -29,7 +29,7 @@ export function App() {

- {section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : selected ? ( + {section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ?

Ovens

Oven catalog coming soon.

: section === "oven-explainer" ?

Oven

Oven details coming soon.

: selected ? ( error ? : loading && !progress ? : progress ? ( ) : diff --git a/dashboard/src/components/StreamingDiff/StreamingDiff.tsx b/dashboard/src/components/StreamingDiff/StreamingDiff.tsx index 0d26feb..0e3da78 100644 --- a/dashboard/src/components/StreamingDiff/StreamingDiff.tsx +++ b/dashboard/src/components/StreamingDiff/StreamingDiff.tsx @@ -24,5 +24,5 @@ export function StreamingDiff({ projects, projectsLoading }: { projects: Project if (autoOpenHref) window.location.replace(autoOpenHref); }, [autoOpenHref]); - return selection ? : 1} />; + return selection ? : 1} />; } diff --git a/dashboard/src/components/StreamingDiff/streaming-diff-dom-golden.test.mjs b/dashboard/src/components/StreamingDiff/streaming-diff-dom-golden.test.mjs index ba27d14..b75e3ce 100644 --- a/dashboard/src/components/StreamingDiff/streaming-diff-dom-golden.test.mjs +++ b/dashboard/src/components/StreamingDiff/streaming-diff-dom-golden.test.mjs @@ -59,7 +59,7 @@ test("selected streaming-diff static DOM matches the frozen byte golden", async import(`${new URL(`file://${componentOutput}`).href}?test=${Date.now()}`), import(`${new URL(`file://${normalizerOutput}`).href}?test=${Date.now()}`), ]); - const backHref = `/ovens/streaming-diff/view?repoKey=${encodeURIComponent(streamingDiffFixture.identity.logicalRepoKey)}`; + const backHref = `/r/${encodeURIComponent(streamingDiffFixture.identity.logicalRepoKey)}/o/streaming-diff`; const markup = withDeterministicTime(() => renderToStaticMarkup(createElement(SelectedFeed, { backHref, cards: streamingDiffFixture.cards, diff --git a/dashboard/src/components/StreamingDiff/streaming-diff-dom.golden.html b/dashboard/src/components/StreamingDiff/streaming-diff-dom.golden.html index 63f747f..92457a4 100644 --- a/dashboard/src/components/StreamingDiff/streaming-diff-dom.golden.html +++ b/dashboard/src/components/StreamingDiff/streaming-diff-dom.golden.html @@ -1 +1 @@ -
Recent feeds

Streaming Diff

Session session-20260718-102030

tool-captured-001
· r-0123456789abcdef
captured
src/streaming-diff.tsmodified
@@ -1 +1 @@ -export const state = "old"; +export const state = "new";
src/new-file.mjsadded
@@ -0,0 +1,2 @@ +export const ready = true; +
notes/obsolete.txtdeleted
@@ -1 +0,0 @@ -remove this note
tool-partial-002
· r-fedcba9876543210
partial

Binary output was captured as metadata only.

assets/preview.binbinary

Binary content is not rendered. · 4096 bytes

+
Recent feeds

Streaming Diff

Session session-20260718-102030

tool-captured-001
· r-0123456789abcdef
captured
src/streaming-diff.tsmodified
@@ -1 +1 @@ -export const state = "old"; +export const state = "new";
src/new-file.mjsadded
@@ -0,0 +1,2 @@ +export const ready = true; +
notes/obsolete.txtdeleted
@@ -1 +0,0 @@ -remove this note
tool-partial-002
· r-fedcba9876543210
partial

Binary output was captured as metadata only.

assets/preview.binbinary

Binary content is not rendered. · 4096 bytes

diff --git a/dashboard/src/lib/hrefs.ts b/dashboard/src/lib/hrefs.ts index 63bfc61..413e372 100644 --- a/dashboard/src/lib/hrefs.ts +++ b/dashboard/src/lib/hrefs.ts @@ -1,55 +1,38 @@ +import { burnlistHref as buildBurnlistHref, parseRoute } from "./route-model.mjs"; import type { Burnlist, Filter, SelectedBurnlist } from "./types"; -const BUILT_IN_OVEN_IDS = new Set([ - "checklist", - "differential-testing", - "model-lab", - "performance-tracing", - "streaming-diff", - "visual-parity", -]); +function route() { + return parseRoute({ pathname: window.location.pathname, search: window.location.search }); +} export function currentSection() { - if (window.location.pathname === "/ovens/new") return "new-oven"; - if (window.location.pathname === "/ovens/differential-testing/view") return "differential-testing"; - if (window.location.pathname === "/ovens/model-lab/view") return "model-lab"; - if (window.location.pathname === "/ovens/performance-tracing/view") return "performance-tracing"; - if (window.location.pathname === "/ovens/streaming-diff/view") return "streaming-diff"; - if (window.location.pathname === "/ovens/visual-parity/view") return "visual-parity"; - const customOvenMatch = window.location.pathname.match(/^\/ovens\/([a-z0-9]+(?:-[a-z0-9]+)*)\/view$/u); - if (customOvenMatch && !BUILT_IN_OVEN_IDS.has(customOvenMatch[1])) return "custom-oven"; - if (window.location.pathname === "/runs/new") return "run-burn"; - return "burnlists"; + return route().section; } export function customOvenSelection(): { id: string; repoKey: string | null } | null { - if (currentSection() !== "custom-oven") return null; - const match = window.location.pathname.match(/^\/ovens\/([a-z0-9]+(?:-[a-z0-9]+)*)\/view$/u); - return match ? { id: match[1], repoKey: new URLSearchParams(window.location.search).get("repoKey") } : null; + const current = route(); + return current.section === "custom-oven" ? { id: current.ovenId, repoKey: current.repoKey } : null; } export function ovenRepoKey() { - return ["differential-testing", "model-lab", "performance-tracing", "streaming-diff", "visual-parity"].includes(currentSection()) - ? new URLSearchParams(window.location.search).get("repoKey") + const current = route(); + return ["differential-testing", "model-lab", "performance-tracing", "streaming-diff", "visual-parity", "custom-oven"].includes(current.section) + ? current.repoKey : null; } export function streamingDiffSelection() { - if (currentSection() !== "streaming-diff") return null; - const params = new URLSearchParams(window.location.search); - const repoKey = params.get("repoKey"); - const worktreeKey = params.get("worktreeKey"); - const session = params.get("session"); - return repoKey && worktreeKey && session ? { repoKey, worktreeKey, session } : null; + const current = route(); + return current.section === "streaming-diff" && current.repoKey && current.worktreeKey && current.session + ? { repoKey: current.repoKey, worktreeKey: current.worktreeKey, session: current.session } + : null; } export function selectedBurnlist(): SelectedBurnlist | null { - if (currentSection() !== "burnlists") return null; - const plan = new URLSearchParams(window.location.search).get("plan"); - if (plan) return { plan }; - const parts = window.location.pathname.split("/").filter(Boolean).map(decodeURIComponent); - if (parts.length === 3 && parts[0] === "r") return { repoKey: parts[1], id: parts[2] }; - return parts.length === 2 ? { repo: parts[0], id: parts[1] } : 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; } export function filterFromUrl(filters: Array<{ value: Filter }>): Filter { @@ -63,8 +46,7 @@ export function listHref(filter: Filter) { export function burnlistHref(entry: Burnlist, filter: Filter, ambiguous = false) { if (ambiguous) return `/?plan=${encodeURIComponent(entry.planPath ?? "")}&filter=${encodeURIComponent(filter)}`; - const path = entry.repoKey - ? `/r/${encodeURIComponent(entry.repoKey)}/${encodeURIComponent(entry.id)}` - : `/${encodeURIComponent(entry.repo)}/${encodeURIComponent(entry.id)}`; - return `${path}?filter=${encodeURIComponent(filter)}`; + return entry.repoKey + ? buildBurnlistHref({ repoKey: entry.repoKey, burnlistId: entry.id, query: { filter } }) + : `/${encodeURIComponent(entry.repo)}/${encodeURIComponent(entry.id)}?filter=${encodeURIComponent(filter)}`; } diff --git a/dashboard/src/lib/index.ts b/dashboard/src/lib/index.ts index 8074a51..c4dd7b8 100644 --- a/dashboard/src/lib/index.ts +++ b/dashboard/src/lib/index.ts @@ -1,5 +1,6 @@ export { formatListTime, formatTime } from "./format"; export { burnlistHref, currentSection, customOvenSelection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist, streamingDiffSelection } from "./hrefs"; +export { differentialTestingScenarioHref, legacyRoute, parseRoute, repoOvenHref } from "./route-model.mjs"; export { adaptPerformanceTracingReport } from "./performance-tracing.mjs"; export { applyStreamingDiffUpdate, fileKindChip, groupStreamingDiffCard, isTextFileKind, mapStreamingDiffFeeds, mapStreamingDiffLandingFeeds, parseStreamingDiffCard, streamingDiffAutoOpenHref, streamingDiffFeedHref, streamingDiffFeedKey, streamingDiffRepositories } from "./streaming-diff.mjs"; export { visualParityDomainSummary } from "./visual-parity"; diff --git a/dashboard/src/lib/route-model.mjs b/dashboard/src/lib/route-model.mjs new file mode 100644 index 0000000..1743317 --- /dev/null +++ b/dashboard/src/lib/route-model.mjs @@ -0,0 +1,99 @@ +const ovenSections = new Map([ + ["differential-testing", "differential-testing"], + ["model-lab", "model-lab"], + ["performance-tracing", "performance-tracing"], + ["streaming-diff", "streaming-diff"], + ["visual-parity", "visual-parity"], +]); + +function decode(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function parts(pathname) { + return String(pathname ?? "").split("/").filter(Boolean).map(decode); +} + +function params(search) { + if (search instanceof URLSearchParams) return new URLSearchParams(search); + return new URLSearchParams(String(search ?? "")); +} + +function queryFields(search, names) { + const source = params(search); + return Object.fromEntries(names.flatMap((name) => source.has(name) ? [[name, source.get(name)]] : [])); +} + +function ovenRoute({ repoKey, burnlistId, ovenId, search }) { + if (burnlistId && ovenId === "checklist") return { section: "burnlist", repoKey, burnlistId, ovenId, ...queryFields(search, ["plan", "filter", "page"]) }; + const section = ovenSections.get(ovenId) ?? "custom-oven"; + const fields = section === "differential-testing" + ? ["scenario", "plan", "filter", "page"] + : section === "streaming-diff" + ? ["worktreeKey", "session", "plan", "filter", "page"] + : ["plan", "filter", "page"]; + return { section, repoKey, ...(burnlistId ? { burnlistId } : {}), ovenId, ...queryFields(search, fields) }; +} + +export function parseRoute({ pathname = "/", search = "" } = {}) { + const path = parts(pathname); + if (path.length === 0) { + const plan = queryFields(search, ["plan", "filter"]); + return plan.plan ? { section: "burnlist", ...plan } : { section: "landing", ...plan }; + } + if (path.length === 1 && path[0] === "ovens") return { section: "ovens-catalog" }; + if (path.length === 2 && path[0] === "ovens") return path[1] === "new" + ? { section: "new-oven" } + : { section: "oven-explainer", ovenId: path[1] }; + if (path.length === 2 && path[0] === "runs" && path[1] === "new") return { section: "run-burn" }; + if (path.length === 3 && path[0] === "r") return { section: "burnlist", repoKey: path[1], burnlistId: path[2], ...queryFields(search, ["plan", "filter", "page"]) }; + if (path.length === 4 && path[0] === "r" && path[2] === "o") return ovenRoute({ repoKey: path[1], ovenId: path[3], search }); + if (path.length === 5 && path[0] === "r" && path[3] === "o") return ovenRoute({ repoKey: path[1], burnlistId: path[2], ovenId: path[4], search }); + if (path.length === 2 && !["api", "ovens", "runs"].includes(path[0])) return { section: "burnlist", repo: path[0], burnlistId: path[1], ...queryFields(search, ["plan", "filter", "page"]) }; + return { section: "landing", ...queryFields(search, ["filter"]) }; +} + +function queryString(query) { + const result = new URLSearchParams(); + if (query instanceof URLSearchParams) { + for (const [key, value] of query) result.append(key, value); + } else if (typeof query === "string") { + for (const [key, value] of new URLSearchParams(query)) result.append(key, value); + } else if (query && typeof query === "object") { + for (const [key, value] of Object.entries(query)) if (value !== null && value !== undefined && value !== "") result.set(key, String(value)); + } + const value = result.toString(); + return value ? `?${value}` : ""; +} + +export function repoOvenHref({ repoKey, ovenId, query } = {}) { + const path = repoKey + ? `/r/${encodeURIComponent(repoKey)}/o/${encodeURIComponent(ovenId)}` + : `/ovens/${encodeURIComponent(ovenId)}`; + return `${path}${queryString(query)}`; +} + +export function burnlistHref({ repoKey, burnlistId, query } = {}) { + return `/r/${encodeURIComponent(repoKey)}/${encodeURIComponent(burnlistId)}${queryString(query)}`; +} + +export function streamingDiffFeedHref({ repoKey, worktreeKey, session } = {}) { + return repoOvenHref({ repoKey, ovenId: "streaming-diff", query: { worktreeKey, session } }); +} + +export function differentialTestingScenarioHref({ repoKey, scenario } = {}) { + return repoOvenHref({ repoKey, ovenId: "differential-testing", query: { scenario } }); +} + +export function legacyRoute({ pathname = "/", search = "" } = {}) { + const match = String(pathname).match(/^\/ovens\/([a-z0-9]+(?:-[a-z0-9]+)*)\/view$/u); + if (!match) return null; + const source = params(search); + const repoKey = source.get("repoKey"); + source.delete("repoKey"); + return repoOvenHref({ repoKey, ovenId: decode(match[1]), query: source }); +} diff --git a/dashboard/src/lib/route-model.test.mjs b/dashboard/src/lib/route-model.test.mjs new file mode 100644 index 0000000..3f1e05f --- /dev/null +++ b/dashboard/src/lib/route-model.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + burnlistHref, + differentialTestingScenarioHref, + legacyRoute, + parseRoute, + repoOvenHref, + streamingDiffFeedHref, +} from "./route-model.mjs"; + +test("parseRoute recognizes the dashboard path model", () => { + assert.deepEqual(parseRoute({ pathname: "/", search: "?filter=active" }), { section: "landing", filter: "active" }); + assert.deepEqual(parseRoute({ pathname: "/ovens", search: "" }), { section: "ovens-catalog" }); + assert.deepEqual(parseRoute({ pathname: "/ovens/new", search: "" }), { section: "new-oven" }); + assert.deepEqual(parseRoute({ pathname: "/ovens/example-oven", search: "" }), { section: "oven-explainer", ovenId: "example-oven" }); + assert.deepEqual(parseRoute({ pathname: "/runs/new", search: "" }), { section: "run-burn" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo%2Fone/list-1", search: "?filter=complete" }), { section: "burnlist", repoKey: "repo/one", burnlistId: "list-1", filter: "complete" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/list", search: "?plan=%2Ftmp%2Fplan.md" }), { section: "burnlist", repoKey: "repo", burnlistId: "list", plan: "/tmp/plan.md" }); + assert.deepEqual(parseRoute({ pathname: "/legacy-repo/list-1", search: "?plan=x" }), { section: "burnlist", repo: "legacy-repo", burnlistId: "list-1", plan: "x" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/o/differential-testing", search: "?scenario=case-1" }), { section: "differential-testing", repoKey: "repo", ovenId: "differential-testing", scenario: "case-1" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/o/model-lab", search: "" }), { section: "model-lab", repoKey: "repo", ovenId: "model-lab" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/o/performance-tracing", search: "" }), { section: "performance-tracing", repoKey: "repo", ovenId: "performance-tracing" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/o/streaming-diff", search: "?worktreeKey=work&session=s1" }), { section: "streaming-diff", repoKey: "repo", ovenId: "streaming-diff", worktreeKey: "work", session: "s1" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/o/visual-parity", search: "" }), { section: "visual-parity", repoKey: "repo", ovenId: "visual-parity" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/list/o/differential-testing", search: "?scenario=case-1" }), { section: "differential-testing", repoKey: "repo", burnlistId: "list", ovenId: "differential-testing", scenario: "case-1" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/list/o/checklist", search: "" }), { section: "burnlist", repoKey: "repo", burnlistId: "list", ovenId: "checklist" }); + assert.deepEqual(parseRoute({ pathname: "/r/repo/list/o/widget-oven", search: "?page=2" }), { section: "custom-oven", repoKey: "repo", burnlistId: "list", ovenId: "widget-oven", page: "2" }); +}); + +test("href builders put repo keys only in path segments", () => { + const hrefs = [ + repoOvenHref({ repoKey: "repo/key", ovenId: "custom oven", query: { filter: "active" } }), + repoOvenHref({ repoKey: null, ovenId: "custom oven" }), + burnlistHref({ repoKey: "repo/key", burnlistId: "burn/list", query: { filter: "active" } }), + streamingDiffFeedHref({ repoKey: "repo/key", worktreeKey: "work tree", session: "session 1" }), + differentialTestingScenarioHref({ repoKey: "repo/key", scenario: "case 1" }), + ]; + assert.deepEqual(hrefs, [ + "/r/repo%2Fkey/o/custom%20oven?filter=active", + "/ovens/custom%20oven", + "/r/repo%2Fkey/burn%2Flist?filter=active", + "/r/repo%2Fkey/o/streaming-diff?worktreeKey=work+tree&session=session+1", + "/r/repo%2Fkey/o/differential-testing?scenario=case+1", + ]); + for (const href of hrefs) assert.doesNotMatch(href, /repoKey=/u); +}); + +test("legacyRoute redirects old oven views and leaves current paths alone", () => { + assert.equal(legacyRoute({ pathname: "/ovens/model-lab/view", search: "?repoKey=repo%2Fkey" }), "/r/repo%2Fkey/o/model-lab"); + assert.equal(legacyRoute({ pathname: "/ovens/differential-testing/view", search: "?scenario=case+1&repoKey=repo%2Fkey" }), "/r/repo%2Fkey/o/differential-testing?scenario=case+1"); + assert.equal(legacyRoute({ pathname: "/ovens/streaming-diff/view", search: "?repoKey=repo%2Fkey&worktreeKey=work+tree&session=session+1" }), "/r/repo%2Fkey/o/streaming-diff?worktreeKey=work+tree&session=session+1"); + assert.equal(legacyRoute({ pathname: "/ovens/custom-oven/view", search: "?repoKey=repo%2Fkey&filter=active" }), "/r/repo%2Fkey/o/custom-oven?filter=active"); + assert.equal(legacyRoute({ pathname: "/ovens/custom-oven/view", search: "?filter=active" }), "/ovens/custom-oven?filter=active"); + assert.equal(legacyRoute({ pathname: "/r/repo/o/model-lab", search: "" }), null); +}); diff --git a/dashboard/src/lib/streaming-diff-oven-adapter.ts b/dashboard/src/lib/streaming-diff-oven-adapter.ts index 9324789..69e93fc 100644 --- a/dashboard/src/lib/streaming-diff-oven-adapter.ts +++ b/dashboard/src/lib/streaming-diff-oven-adapter.ts @@ -21,6 +21,6 @@ export function adaptStreamingDiff(snapshot: StreamingDiffSnapshot): StreamingDi identity, updatedAt, cards: snapshot.cards.map(parseStreamingDiffCard).filter((card): card is StreamingDiffCard => card !== null), - backHref: `/ovens/streaming-diff/view?repoKey=${encodeURIComponent(identity.logicalRepoKey)}`, + backHref: `/r/${encodeURIComponent(identity.logicalRepoKey)}/o/streaming-diff`, }; } diff --git a/dashboard/src/lib/streaming-diff.mjs b/dashboard/src/lib/streaming-diff.mjs index 0057492..2e3c215 100644 --- a/dashboard/src/lib/streaming-diff.mjs +++ b/dashboard/src/lib/streaming-diff.mjs @@ -45,8 +45,8 @@ function parseFile(value) { } export function streamingDiffFeedHref({ logicalRepoKey, worktreeKey, session }) { - const params = new URLSearchParams({ repoKey: logicalRepoKey, worktreeKey, session }); - return `/ovens/streaming-diff/view?${params}`; + const params = new URLSearchParams({ worktreeKey, session }); + return `/r/${encodeURIComponent(logicalRepoKey)}/o/streaming-diff?${params}`; } export function mapStreamingDiffFeeds(value) { diff --git a/dashboard/src/lib/streaming-diff.test.mjs b/dashboard/src/lib/streaming-diff.test.mjs index 2393fc4..9dd7ae2 100644 --- a/dashboard/src/lib/streaming-diff.test.mjs +++ b/dashboard/src/lib/streaming-diff.test.mjs @@ -14,7 +14,7 @@ test("recent feed mapping sorts published activity and creates session deep link }); assert.deepEqual(feeds.map((feed) => feed.identity.session), ["newer", "older session"]); - assert.equal(feeds[1].href, "/ovens/streaming-diff/view?repoKey=aaaaaaaaaaaa&worktreeKey=111111111111&session=older+session"); + assert.equal(feeds[1].href, "/r/aaaaaaaaaaaa/o/streaming-diff?worktreeKey=111111111111&session=older+session"); }); test("landing derives repositories and aggregates their feeds by recent activity", () => { diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 2bcdca8..1b6817d 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -2,6 +2,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import { App } from "@/App"; +import { legacyRoute } from "@/lib"; + +const redirected = legacyRoute({ pathname: window.location.pathname, search: window.location.search }); +if (redirected) window.history.replaceState(null, "", redirected); createRoot(document.getElementById("root")!).render( diff --git a/dashboard/src/oven/FeedList/FeedList.test.ts b/dashboard/src/oven/FeedList/FeedList.test.ts index d09e81b..e9ba484 100644 --- a/dashboard/src/oven/FeedList/FeedList.test.ts +++ b/dashboard/src/oven/FeedList/FeedList.test.ts @@ -21,21 +21,21 @@ test("FeedList preserves the empty state", () => { test("FeedList preserves repository details when requested", () => { const feed = { identity: { logicalRepoKey: "repo-key", worktreeKey: "worktree-1", session: "session-1" }, - updatedAt: "2026-07-18T13:14:15.000Z", href: "/ovens/streaming-diff/view?session=session-1", repoLabel: "Example repository", + updatedAt: "2026-07-18T13:14:15.000Z", href: "/r/repo-key/o/streaming-diff?worktreeKey=worktree-1&session=session-1", repoLabel: "Example repository", }; const timestamp = new Date(feed.updatedAt).toLocaleString(); const markup = renderToStaticMarkup(createElement(FeedList, { feeds: [feed], error: "", loading: false, showRepository: true })); - assert.equal(markup, `${heading}`); + assert.equal(markup, `${heading}`); }); test("FeedList omits repository details when not requested", () => { const feed = { identity: { logicalRepoKey: "repo-key", worktreeKey: "worktree-1", session: "session-1" }, - updatedAt: "2026-07-18T13:14:15.000Z", href: "/ovens/streaming-diff/view?session=session-1", repoLabel: "Example repository", + updatedAt: "2026-07-18T13:14:15.000Z", href: "/r/repo-key/o/streaming-diff?worktreeKey=worktree-1&session=session-1", repoLabel: "Example repository", }; const markup = renderToStaticMarkup(createElement(FeedList, { feeds: [feed], error: "", loading: false, showRepository: false })); - assert.equal(markup, `${heading}`); + assert.equal(markup, `${heading}`); assert.doesNotMatch(markup, /repository Example repository/u); }); diff --git a/dashboard/src/oven/StreamingDiffHeading/StreamingDiffHeading.test.ts b/dashboard/src/oven/StreamingDiffHeading/StreamingDiffHeading.test.ts index 34dc8ef..59cee48 100644 --- a/dashboard/src/oven/StreamingDiffHeading/StreamingDiffHeading.test.ts +++ b/dashboard/src/oven/StreamingDiffHeading/StreamingDiffHeading.test.ts @@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { StreamingDiffHeading } from "./StreamingDiffHeading"; test("StreamingDiffHeading preserves the selected-feed heading markup", () => { - const backHref = "/ovens/streaming-diff/view?repoKey=example%2Frepo"; + const backHref = "/r/example%2Frepo/o/streaming-diff"; const markup = renderToStaticMarkup(createElement(StreamingDiffHeading, { backHref, session: "session-123" })); assert.equal(markup, `
Recent feeds

Streaming Diff

Session session-123

`); diff --git a/dashboard/src/oven/differential-testing-render/differential-testing-renderer.js b/dashboard/src/oven/differential-testing-render/differential-testing-renderer.js index 23a832a..ba3c028 100644 --- a/dashboard/src/oven/differential-testing-render/differential-testing-renderer.js +++ b/dashboard/src/oven/differential-testing-render/differential-testing-renderer.js @@ -670,7 +670,7 @@ export function startDifferentialTestingLiveUpdates(root, { fieldViewQuery = null; payloadCache.clear(); try { - const nextUrl = new URL(locationImpl?.href || "/ovens/differential-testing/view", "http://localhost"); + const nextUrl = new URL(locationImpl?.href || "/r/repository/o/differential-testing", "http://localhost"); nextUrl.searchParams.set("scenario", scenarioId); historyImpl?.replaceState?.(null, "", `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`); } catch {} @@ -706,7 +706,7 @@ export function startDifferentialTestingLiveUpdates(root, { }; } -const differentialRoot = typeof document === "undefined" || globalThis.location?.pathname !== "/ovens/differential-testing/view" +const differentialRoot = typeof document === "undefined" || !/^\/r\/[^/]+\/o\/differential-testing$/u.test(globalThis.location?.pathname ?? "") ? null : document.querySelector(".shell.driving-parity-view"); if (differentialRoot) { diff --git a/dashboard/src/oven/runtime/oven-live-data.test.ts b/dashboard/src/oven/runtime/oven-live-data.test.ts index fc7e119..b3c3f33 100644 --- a/dashboard/src/oven/runtime/oven-live-data.test.ts +++ b/dashboard/src/oven/runtime/oven-live-data.test.ts @@ -52,6 +52,18 @@ test("scenario selection rekeys poller requests while retaining the repository k assert.equal(ovenDataUrl("differential-testing", scenarioSearch("", "X")), "/api/oven-data/differential-testing?scenario=X"); }); +test("oven data API calls inject the path-scoped repository key", () => { + const target = globalThis as { window?: { location: { pathname: string; search: string } } }; + const original = target.window; + target.window = { location: { pathname: "/r/repo%2Fkey/o/differential-testing", search: "?scenario=case-1" } }; + try { + assert.equal(ovenDataUrl("differential-testing"), "/api/oven-data/differential-testing?repoKey=repo%2Fkey&scenario=case-1"); + } finally { + if (original) target.window = original; + else delete target.window; + } +}); + test("oven poller adapts DT and PT envelopes while unadapted polls retain raw bodies", async () => { const fixture = await import(pathToFileURL(resolve(process.cwd(), "dashboard/src/oven/differential-testing-render/golden-harness.mjs")).href); const dtReport = fixture.differentialTestingPayload(); diff --git a/dashboard/src/oven/runtime/oven-live-data.ts b/dashboard/src/oven/runtime/oven-live-data.ts index 8492989..4105383 100644 --- a/dashboard/src/oven/runtime/oven-live-data.ts +++ b/dashboard/src/oven/runtime/oven-live-data.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { parseRoute } from "../../lib/route-model.mjs"; import type { OvenAction, OvenIr, OvenState } from "./oven-reducer"; type FetchLike = (input: string, init: RequestInit) => Promise<{ ok: boolean; status: number; headers: { get(name: string): string | null }; json(): Promise }>; @@ -14,6 +15,15 @@ export function scenarioSearch(currentSearch = typeof window === "undefined" ? " return query ? `?${query}` : ""; } +function browserSearchWithRepoKey() { + if (typeof window === "undefined") return ""; + const source = new URLSearchParams(window.location.search); + const { repoKey } = parseRoute({ pathname: window.location.pathname, search: window.location.search }); + if (repoKey) source.set("repoKey", repoKey); + const query = source.toString(); + return query ? `?${query}` : ""; +} + type IrNode = { attributes?: Record; children?: IrNode[] }; function nodes(items: IrNode[] = []): IrNode[] { return items.flatMap((node) => [node, ...nodes(node.children)]); } @@ -22,7 +32,7 @@ function attributes(ir: OvenIr, id: string): Record { } export function ovenPollSearch({ ir, state, scenario }: { ir: OvenIr; state: OvenState; scenario?: string }): string { - const base = scenarioSearch(undefined, scenario); + const base = scenarioSearch(browserSearchWithRepoKey(), scenario); for (const item of ir.collections) { const collection = { ...attributes(ir, item.id), ...item }; const current = state.collections[item.id]; @@ -41,7 +51,7 @@ export function ovenPollSearch({ ir, state, scenario }: { ir: OvenIr; state: Ove return base; } -export function ovenDataUrl(id: string, search = typeof window === "undefined" ? "" : window.location.search): string { +export function ovenDataUrl(id: string, search = browserSearchWithRepoKey()): string { return `/api/oven-data/${encodeURIComponent(id)}${scenarioSearch(search)}`; } diff --git a/ovens/differential-testing/engine/handler.mjs b/ovens/differential-testing/engine/handler.mjs index dcd0fa8..a0b28fc 100644 --- a/ovens/differential-testing/engine/handler.mjs +++ b/ovens/differential-testing/engine/handler.mjs @@ -192,7 +192,9 @@ export const differentialTestingHandler = Object.freeze({ status: "active", statusLabel: "Active", total: scenario.frameCount, done: null, remaining: null, percent: null, errors: 0, warnings: 0, lastCompletedAt: null, updatedAt: scenario.updatedAt, ovenId: "differential-testing", ovenName: "Differential Testing", - href: `/ovens/differential-testing/view?scenario=${encodeURIComponent(scenario.id)}${binding.repoKey === null ? "" : `&repoKey=${encodeURIComponent(binding.repoKey)}`}`, + href: binding.repoKey === null + ? "/ovens/differential-testing" + : `/r/${encodeURIComponent(binding.repoKey)}/o/differential-testing?${new URLSearchParams({ scenario: scenario.id })}`, progressLabel: `${scenario.frameCount} frames`, })); } catch (error) { @@ -202,7 +204,7 @@ export const differentialTestingHandler = Object.freeze({ title: "Differential Testing", planPath: null, planLabel: "Oven data binding", status: "active", statusLabel: "Blocked", total: 0, done: null, remaining: null, percent: null, errors: 1, warnings: 0, lastCompletedAt: null, updatedAt: null, - ovenId: "differential-testing", ovenName: "Differential Testing", href: "/ovens/differential-testing/view", + ovenId: "differential-testing", ovenName: "Differential Testing", href: "/ovens/differential-testing", progressLabel: "Blocked", blockers: String(error?.message ?? error ?? "Data binding is unavailable.").slice(0, 200), }]; } diff --git a/ovens/differential-testing/engine/transport-poll.test.mjs b/ovens/differential-testing/engine/transport-poll.test.mjs index e7b92f1..eb6e406 100644 --- a/ovens/differential-testing/engine/transport-poll.test.mjs +++ b/ovens/differential-testing/engine/transport-poll.test.mjs @@ -46,7 +46,7 @@ function tick() { return new Promise((resolve) => setImmediate(resolve)); } -function createHarness({ respond, locationSearch = "", locationHref = "http://localhost/ovens/differential-testing/view", onError } = {}) { +function createHarness({ respond, locationSearch = "", locationHref = "http://localhost/r/repository/o/differential-testing", onError } = {}) { const requests = []; const updates = []; const statuses = []; @@ -206,7 +206,7 @@ test("selectScenario updates history, clears its URL cache, and refetches the se let payloadCount = 0; const harness = createHarness({ locationSearch: "?scenario=oldid", - locationHref: "http://localhost/ovens/differential-testing/view?scenario=oldid", + locationHref: "http://localhost/r/repository/o/differential-testing?scenario=oldid", respond(url) { if (url === ovenUrl) return response({ oven: { detail: { cells: [] } } }); payloadCount += 1; diff --git a/ovens/differential-testing/engine/transport-renderer.test.mjs b/ovens/differential-testing/engine/transport-renderer.test.mjs index 380f581..7aad696 100644 --- a/ovens/differential-testing/engine/transport-renderer.test.mjs +++ b/ovens/differential-testing/engine/transport-renderer.test.mjs @@ -73,7 +73,7 @@ test("live updates preserve field-page metadata and issue server-side view queri }); const controller = startDifferentialTestingLiveUpdates({ innerHTML: "" }, { - locationImpl: { search: `?scenario=${scenarioId}`, href: `http://localhost/ovens/differential-testing/view?scenario=${scenarioId}` }, + locationImpl: { search: `?scenario=${scenarioId}`, href: `http://localhost/r/repository/o/differential-testing?scenario=${scenarioId}` }, historyImpl: { replaceState() {} }, fetchImpl: async (url) => { requests.push(url); diff --git a/ovens/model-lab/engine/model-lab-handler.mjs b/ovens/model-lab/engine/model-lab-handler.mjs index 11d96d7..3a28c85 100644 --- a/ovens/model-lab/engine/model-lab-handler.mjs +++ b/ovens/model-lab/engine/model-lab-handler.mjs @@ -48,7 +48,7 @@ export const modelLabHandler = Object.freeze({ updatedAt: payload.generatedAt ?? stat.mtime.toISOString(), ovenId: "model-lab", ovenName: "Model Lab", - href: `/ovens/model-lab/view${binding.repoKey === null ? "" : `?repoKey=${encodeURIComponent(binding.repoKey)}`}`, + href: binding.repoKey === null ? "/ovens/model-lab" : `/r/${encodeURIComponent(binding.repoKey)}/o/model-lab`, progressLabel: `${payload.model.leafCount} leaves · no LOD`, }; } catch (error) { @@ -72,7 +72,7 @@ export const modelLabHandler = Object.freeze({ updatedAt: null, ovenId: "model-lab", ovenName: "Model Lab", - href: "/ovens/model-lab/view", + href: "/ovens/model-lab", progressLabel: "Blocked", blockers: String(error?.message ?? error ?? "Data binding is unavailable.").slice(0, 200), }; diff --git a/ovens/visual-parity/handler.mjs b/ovens/visual-parity/handler.mjs index d0b1c20..4c73452 100644 --- a/ovens/visual-parity/handler.mjs +++ b/ovens/visual-parity/handler.mjs @@ -51,7 +51,7 @@ export const visualParityHandler = Object.freeze({ lastCompletedAt: complete ? payload.differentialTesting.publishedAt : null, updatedAt: payload.differentialTesting.publishedAt ?? stat.mtime.toISOString(), ovenId: "visual-parity", ovenName: "Visual Parity", - href: `/ovens/visual-parity/view${binding.repoKey === null ? "" : `?repoKey=${encodeURIComponent(binding.repoKey)}`}`, + href: binding.repoKey === null ? "/ovens/visual-parity" : `/r/${encodeURIComponent(binding.repoKey)}/o/visual-parity`, progressLabel: `${progress.qualified}/${progress.total} target frames`, }; } catch (error) { @@ -62,7 +62,7 @@ export const visualParityHandler = Object.freeze({ status: "active", statusLabel: "Blocked", total: 0, done: null, remaining: null, percent: null, errors: 1, warnings: 0, lastCompletedAt: null, updatedAt: null, ovenId: "visual-parity", ovenName: "Visual Parity", - href: `/ovens/visual-parity/view${binding.repoKey === null ? "" : `?repoKey=${encodeURIComponent(binding.repoKey)}`}`, + href: binding.repoKey === null ? "/ovens/visual-parity" : `/r/${encodeURIComponent(binding.repoKey)}/o/visual-parity`, progressLabel: "Blocked", blockers: String(error?.message ?? error ?? "Data binding is unavailable.").slice(0, 200), }; } diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 777280d..271cd1a 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -1,4 +1,5 @@ export const verificationTestFiles = [ + "dashboard/src/lib/route-model.test.mjs", "src/server/dashboard-routes.test.mjs", "src/server/oven-event-routes.test.mjs", "src/events/oven-event-feed.test.mjs", diff --git a/skills/burnlist/references/oven-authoring.md b/skills/burnlist/references/oven-authoring.md index c41136e..c699d5d 100644 --- a/skills/burnlist/references/oven-authoring.md +++ b/skills/burnlist/references/oven-authoring.md @@ -129,7 +129,7 @@ burnlist --scan-root The server is loopback-only and normally opens at `http://127.0.0.1:4510/`; add `--auto-port` to select a free port. A bound custom Oven appears in the index as a **Custom Oven** with status **Oven**. Clicking it opens -`/ovens//view?repoKey=` and renders it through the shared engine using +`/r//o/` and renders it through the shared engine using the bound JSON. An unbound custom Oven is authored but does not appear there. For a one-dashboard-session alternative that does not write `bindings.json`, diff --git a/src/cli/streaming-diff-cli.mjs b/src/cli/streaming-diff-cli.mjs index bcd51f7..0a55e41 100644 --- a/src/cli/streaming-diff-cli.mjs +++ b/src/cli/streaming-diff-cli.mjs @@ -189,7 +189,7 @@ try { const session = one(flags, "session", { required: true }); if ([...flags.keys()].some((key) => key !== "session")) fail("url accepts only --session.", 2); const identity = resolveStreamingDiffIdentity({ session }); - console.log(`/ovens/streaming-diff/view?repoKey=${encodeURIComponent(identity.logicalRepoKey)}&worktreeKey=${encodeURIComponent(identity.worktreeKey)}&session=${encodeURIComponent(identity.session)}`); + console.log(`/r/${encodeURIComponent(identity.logicalRepoKey)}/o/streaming-diff?${new URLSearchParams({ worktreeKey: identity.worktreeKey, session: identity.session })}`); return; } fail(`unknown subcommand "${subcommand}". Run \`burnlist streaming-diff help\`.`, 2); diff --git a/src/cli/streaming-diff-cli.test.mjs b/src/cli/streaming-diff-cli.test.mjs index 4de8424..fdf2a41 100644 --- a/src/cli/streaming-diff-cli.test.mjs +++ b/src/cli/streaming-diff-cli.test.mjs @@ -79,7 +79,7 @@ test("CLI url prints the URL-addressed logical/worktree/session route", () => { const identity = resolveStreamingDiffIdentity({ cwd: context.root, session: "agent-one" }); assert.equal( run(context, "url", "--session", "agent-one").trim(), - `/ovens/streaming-diff/view?repoKey=${identity.logicalRepoKey}&worktreeKey=${identity.worktreeKey}&session=agent-one`, + `/r/${identity.logicalRepoKey}/o/streaming-diff?worktreeKey=${identity.worktreeKey}&session=agent-one`, ); } finally { context.cleanup(); } }); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 6ff0e30..2fa1296 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -342,7 +342,7 @@ function customOvenDashboardEntries(ctx) { updatedAt: null, ovenId: oven.id, ovenName: oven.name, - href: `/ovens/${encodeURIComponent(oven.id)}/view?repoKey=${encodeURIComponent(oven.repoKey)}`, + href: `/r/${encodeURIComponent(oven.repoKey)}/o/${encodeURIComponent(oven.id)}`, progressLabel: "Custom Oven", }); } catch { @@ -1287,9 +1287,13 @@ const server = createServer(async (req, res) => { json(res, 200, { run }); return; } - const ovenViewRoute = url.pathname.match(/^\/ovens\/([a-z0-9]+(?:-[a-z0-9]+)*)\/view$/u); - const isKnownOvenView = Boolean(ovenViewRoute && findOven(ovenViewRoute[1], selectedRepoKey(url))); - if (["/", "/index.html", "/ovens/new", "/runs/new"].includes(url.pathname) || isKnownOvenView || routeSelection(url)) { + const ovenIdPattern = "[a-z0-9]+(?:-[a-z0-9]+)*"; + const ovenViewRoute = url.pathname.match(new RegExp(`^/ovens/(${ovenIdPattern})/view$`, "u")); + const isLegacyOvenView = Boolean(ovenViewRoute); + const ovenCatalogRoute = new RegExp(`^/ovens/${ovenIdPattern}$`, "u").test(url.pathname); + const repoOvenRoute = new RegExp(`^/r/[a-f0-9]{12}/o/${ovenIdPattern}$`, "u").test(url.pathname); + const burnlistOvenRoute = new RegExp(`^/r/[a-f0-9]{12}/${ovenIdPattern}/o/${ovenIdPattern}$`, "u").test(url.pathname); + if (["/", "/index.html", "/ovens", "/ovens/new", "/runs/new"].includes(url.pathname) || ovenCatalogRoute || repoOvenRoute || burnlistOvenRoute || isLegacyOvenView || routeSelection(url)) { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); serveDashboardShell(res); return; diff --git a/src/server/custom-oven-index.test.mjs b/src/server/custom-oven-index.test.mjs index f9210c5..d6ea151 100644 --- a/src/server/custom-oven-index.test.mjs +++ b/src/server/custom-oven-index.test.mjs @@ -16,7 +16,7 @@ test("a bound custom Oven appears in the dashboard index with a working data sel const burnlists = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; const entry = burnlists.find((candidate) => candidate.ovenId === "widget-oven"); assert.ok(entry); - assert.match(entry.href, /^\/ovens\/widget-oven\/view\?repoKey=/u); + assert.match(entry.href, /^\/r\/[a-f0-9]{12}\/o\/widget-oven$/u); const catalog = JSON.parse((await httpGet(baseUrl, "/api/ovens")).body); const oven = catalog.ovens.find((candidate) => candidate.id === "widget-oven"); @@ -24,7 +24,7 @@ test("a bound custom Oven appears in the dashboard index with a working data sel assert.notEqual(entry.repoKey, null); assert.equal(entry.repoKey, oven.repoKey); - const repoKey = new URL(entry.href, baseUrl).searchParams.get("repoKey"); + const repoKey = decodeURIComponent(new URL(entry.href, baseUrl).pathname.split("/")[2]); assert.equal(repoKey, oven.repoKey); assert.equal((await httpGet(baseUrl, `/api/oven-data/widget-oven?repoKey=${encodeURIComponent(repoKey)}`)).status, 200); }); diff --git a/src/server/custom-oven-view-routes.test.mjs b/src/server/custom-oven-view-routes.test.mjs index 89678a2..03df9d8 100644 --- a/src/server/custom-oven-view-routes.test.mjs +++ b/src/server/custom-oven-view-routes.test.mjs @@ -28,7 +28,7 @@ test("a custom Oven view serves compiled IR and author-shaped bound data", { tim assert.deepEqual(JSON.parse(dataResponse.body).payload, payload); assert.equal(JSON.parse(dataResponse.body).validated, false); - const viewResponse = await httpGet(baseUrl, `/ovens/widget-oven/view${query}`); + const viewResponse = await httpGet(baseUrl, `/r/${encodeURIComponent(repoKey)}/o/widget-oven`); assert.equal(viewResponse.status, 200); assert.ok(viewResponse.body.length > 0); }); diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 3f6d2e1..5330870 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -9,6 +9,18 @@ test("root serves the dashboard shell", { timeout: 20_000 }, async () => { }); }); +test("reserved and path-scoped dashboard routes serve the SPA shell", { timeout: 20_000 }, async () => { + await withServer({ withBurnlist: true }, async ({ baseUrl }) => { + for (const pathname of [ + "/ovens", + "/ovens/example-oven", + "/r/aaaaaaaaaaaa/o/model-lab", + "/r/aaaaaaaaaaaa/fixture/o/streaming-diff", + "/ovens/example-oven/view", + ]) assert.equal((await httpGet(baseUrl, pathname)).status, 200); + }); +}); + test("/api/progress requires an explicit selection when one Burnlist is active", { timeout: 20_000 }, async () => { await withServer({ withBurnlist: true }, async ({ baseUrl }) => { const response = await httpGet(baseUrl, "/api/progress"); diff --git a/src/server/oven-routes.test.mjs b/src/server/oven-routes.test.mjs index 46d91d2..969daed 100644 --- a/src/server/oven-routes.test.mjs +++ b/src/server/oven-routes.test.mjs @@ -110,7 +110,7 @@ test("a generated Differential Testing row href resolves (global built-in, repoK const rows = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; const dtRow = rows.find((row) => row.ovenId === "differential-testing" && row.statusLabel !== "Blocked"); assert.ok(dtRow, "expected a Differential Testing dashboard row"); - assert.match(dtRow.href, /\/ovens\/differential-testing\/view\?.*repoKey=/u); + assert.match(dtRow.href, /^\/r\/[a-f0-9]{12}\/o\/differential-testing\?scenario=/u); // The generated row link (built-in oven + a repoKey data selector) must resolve, not 404. assert.equal((await httpGet(baseUrl, dtRow.href)).status, 200); // The oven itself is global; repoKey only selects that repo's data binding. @@ -275,7 +275,7 @@ test("Differential Testing bindings remain distinct for each repository", { time for (const entry of entries) { assert.equal(entry.planPath, null); assert.equal(entry.planLabel, null); - assert.match(entry.href, new RegExp(`^/ovens/differential-testing/view\\?scenario=${entry.id}&repoKey=${entry.repoKey}$`, "u")); + assert.match(entry.href, new RegExp(`^/r/${entry.repoKey}/o/differential-testing\\?scenario=${entry.id}$`, "u")); } const firstEntry = entries.find((entry) => entry.title === "first-repo"); diff --git a/website/src/content/docs/ovens/authoring.mdx b/website/src/content/docs/ovens/authoring.mdx index dd3566c..83c7c75 100644 --- a/website/src/content/docs/ovens/authoring.mdx +++ b/website/src/content/docs/ovens/authoring.mdx @@ -169,7 +169,7 @@ burnlist --scan-root It is loopback-only at `http://127.0.0.1:4510/` by default; add `--auto-port` to select a free port. A bound custom Oven appears on the index as a **Custom Oven** with status **Oven**. Selecting it opens -`/ovens//view?repoKey=` and renders the bound payload. An unbound +`/r//o/` and renders the bound payload. An unbound custom Oven is authored but does not appear there. For a read-only binding for one dashboard session, without writing From a1016a86688441956263c8e6648d78d8bb4f4b4e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 20:54:42 +0200 Subject: [PATCH 04/21] feat: burnlist lens-switcher of contract-fit ovens (260721-001) --- dashboard/src/App.tsx | 4 +- .../CustomOvenView/CustomOvenView.tsx | 16 ++++--- .../components/LensSwitcher/LensSwitcher.css | 20 +++++++++ .../components/LensSwitcher/LensSwitcher.tsx | 42 +++++++++++++++++++ .../src/components/LensSwitcher/index.ts | 1 + dashboard/src/components/index.ts | 1 + dashboard/src/lib/hrefs.ts | 11 ++++- dashboard/src/lib/index.ts | 5 ++- dashboard/src/lib/oven-fit.mjs | 13 ++++++ dashboard/src/lib/oven-fit.test.mjs | 24 +++++++++++ dashboard/src/lib/route-model.mjs | 4 ++ dashboard/src/lib/route-model.test.mjs | 4 ++ dashboard/src/lib/types.ts | 1 + scripts/verify-test-files.mjs | 1 + src/server/burnlist-dashboard-server.mjs | 1 + src/server/oven-routes.test.mjs | 3 +- 16 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 dashboard/src/components/LensSwitcher/LensSwitcher.css create mode 100644 dashboard/src/components/LensSwitcher/LensSwitcher.tsx create mode 100644 dashboard/src/components/LensSwitcher/index.ts create mode 100644 dashboard/src/lib/oven-fit.mjs create mode 100644 dashboard/src/lib/oven-fit.test.mjs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 7b8f845..8e225e7 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { ListChecks } from "lucide-react"; -import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, ModelLabPage, NewOvenPage, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; +import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; import { useDashboardData } from "@hooks"; import { currentSection, filterFromUrl, selectedBurnlist } from "@lib"; import type { Filter } from "@lib"; @@ -31,7 +31,7 @@ export function App() {
{section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ?

Ovens

Oven catalog coming soon.

: section === "oven-explainer" ?

Oven

Oven details coming soon.

: selected ? ( error ? : loading && !progress ? : progress ? ( - + <> ) : ) : (
diff --git a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx index 2b4e2a5..9f1e435 100644 --- a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx +++ b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx @@ -1,8 +1,11 @@ import { useEffect, useState, type ComponentProps } from "react"; import { customOvenSelection } from "@lib"; +import { adaptChecklist } from "@lib/checklist-adapter"; +import type { ChecklistProgressData } from "@lib"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; import { DashboardError } from "../DashboardError"; import { EmptyState } from "../EmptyState"; +import { LensSwitcher } from "../LensSwitcher"; type OvenIr = ComponentProps["ir"]; type LoadedOven = { ir: OvenIr; payload: unknown }; @@ -30,13 +33,15 @@ export function CustomOvenView() { try { const [ovenResponse, dataResponse] = await Promise.all([ fetch(`/api/ovens/${selection.id}${query}`, { cache: "no-store", signal: controller.signal }), - fetch(`/api/oven-data/${selection.id}${query}`, { cache: "no-store", signal: controller.signal }), + fetch(selection.burnlistId + ? `/api/progress?repoKey=${encodeURIComponent(selection.repoKey)}&id=${encodeURIComponent(selection.burnlistId)}` + : `/api/oven-data/${selection.id}${query}`, { cache: "no-store", signal: controller.signal }), ]); if (!ovenResponse.ok) throw new Error(`Could not load Oven (${ovenResponse.status}).`); - if (!dataResponse.ok) throw new Error(`Could not load Oven data (${dataResponse.status}).`); + if (!dataResponse.ok) throw new Error(`Could not load ${selection.burnlistId ? "Burnlist progress" : "Oven data"} (${dataResponse.status}).`); const [ovenBody, dataBody] = await Promise.all([ovenResponse.json(), dataResponse.json()]); if (controller.signal.aborted) return; - setLoaded({ ir: ovenBody.oven.ir, payload: dataBody.payload }); + setLoaded({ ir: ovenBody.oven.ir, payload: selection.burnlistId ? adaptChecklist(dataBody as ChecklistProgressData) : dataBody.payload }); } catch (cause) { if (controller.signal.aborted) return; setError(cause instanceof Error ? cause.message : "Could not load the custom Oven."); @@ -44,9 +49,10 @@ export function CustomOvenView() { }; void load(); return () => controller.abort(); - }, [selection?.id, selection?.repoKey]); + }, [selection?.burnlistId, selection?.id, selection?.repoKey]); if (error) return ; if (!loaded) return ; - return ; + const runtime = ; + return selection?.burnlistId ? <>{runtime} : runtime; } diff --git a/dashboard/src/components/LensSwitcher/LensSwitcher.css b/dashboard/src/components/LensSwitcher/LensSwitcher.css new file mode 100644 index 0000000..e98f117 --- /dev/null +++ b/dashboard/src/components/LensSwitcher/LensSwitcher.css @@ -0,0 +1,20 @@ +.lens-switcher { + display: flex; + gap: 0.35rem; + margin: 0 0 1rem; +} + +.lens-switcher-link { + border: 1px solid var(--border, #d1d5db); + border-radius: 999px; + color: inherit; + font-size: 0.8125rem; + padding: 0.35rem 0.65rem; + text-decoration: none; +} + +.lens-switcher-link.is-active { + background: var(--accent, #2563eb); + border-color: var(--accent, #2563eb); + color: #fff; +} diff --git a/dashboard/src/components/LensSwitcher/LensSwitcher.tsx b/dashboard/src/components/LensSwitcher/LensSwitcher.tsx new file mode 100644 index 0000000..91be153 --- /dev/null +++ b/dashboard/src/components/LensSwitcher/LensSwitcher.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { BURNLIST_DATA_CONTRACT, burnlistLensContext, burnlistOvenHref, fittingOvens } from "@lib"; +import type { OvenSummary } from "@lib"; +import "./LensSwitcher.css"; + +export function LensSwitcher() { + const context = burnlistLensContext(); + const [ovens, setOvens] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!context) return; + const controller = new AbortController(); + const load = async () => { + setOvens(null); + setFailed(false); + try { + const response = await fetch("/api/ovens", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error("Could not load Ovens."); + const payload = await response.json() as { ovens?: OvenSummary[] }; + if (!controller.signal.aborted) setOvens(Array.isArray(payload.ovens) ? payload.ovens : []); + } catch { + if (!controller.signal.aborted) setFailed(true); + } + }; + void load(); + return () => controller.abort(); + }, [context?.repoKey, context?.burnlistId]); + + if (!context || !ovens || failed) return null; + const lenses = fittingOvens(ovens, BURNLIST_DATA_CONTRACT, { repoKey: context.repoKey }); + if (!lenses.length) return null; + + return ( + + ); +} diff --git a/dashboard/src/components/LensSwitcher/index.ts b/dashboard/src/components/LensSwitcher/index.ts new file mode 100644 index 0000000..99a83e8 --- /dev/null +++ b/dashboard/src/components/LensSwitcher/index.ts @@ -0,0 +1 @@ +export { LensSwitcher } from "./LensSwitcher"; diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts index e7fdf0f..af5ddd9 100644 --- a/dashboard/src/components/index.ts +++ b/dashboard/src/components/index.ts @@ -6,6 +6,7 @@ export { DashboardError } from "./DashboardError"; export { DifferentialTestingOvenPage, PerformanceTracingOvenPage } from "./DifferentialTestingOven"; export { EmptyState } from "./EmptyState"; export { FILTERS, Filters } from "./Filters"; +export { LensSwitcher } from "./LensSwitcher"; export { ModelLabPage } from "./ModelLab"; export { BurnlistTable, ProjectGroup } from "./ProjectGroup"; export { StreamingDiff } from "./StreamingDiff"; diff --git a/dashboard/src/lib/hrefs.ts b/dashboard/src/lib/hrefs.ts index 413e372..edd048f 100644 --- a/dashboard/src/lib/hrefs.ts +++ b/dashboard/src/lib/hrefs.ts @@ -9,9 +9,16 @@ export function currentSection() { return route().section; } -export function customOvenSelection(): { id: string; repoKey: string | null } | null { +export function customOvenSelection(): { id: string; repoKey: string | null; burnlistId: string | null } | null { const current = route(); - return current.section === "custom-oven" ? { id: current.ovenId, repoKey: current.repoKey } : null; + return current.section === "custom-oven" ? { id: current.ovenId, repoKey: current.repoKey, burnlistId: current.burnlistId ?? null } : null; +} + +export function burnlistLensContext(): { repoKey: string; burnlistId: string; activeOvenId: string } | null { + const current = route(); + if (!current.repoKey || !current.burnlistId) return null; + if (current.section === "burnlist") return { repoKey: current.repoKey, burnlistId: current.burnlistId, activeOvenId: current.ovenId ?? "checklist" }; + return current.ovenId ? { repoKey: current.repoKey, burnlistId: current.burnlistId, activeOvenId: current.ovenId } : null; } export function ovenRepoKey() { diff --git a/dashboard/src/lib/index.ts b/dashboard/src/lib/index.ts index c4dd7b8..be4f275 100644 --- a/dashboard/src/lib/index.ts +++ b/dashboard/src/lib/index.ts @@ -1,6 +1,7 @@ export { formatListTime, formatTime } from "./format"; -export { burnlistHref, currentSection, customOvenSelection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist, streamingDiffSelection } from "./hrefs"; -export { differentialTestingScenarioHref, legacyRoute, parseRoute, repoOvenHref } from "./route-model.mjs"; +export { burnlistHref, burnlistLensContext, currentSection, customOvenSelection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist, streamingDiffSelection } from "./hrefs"; +export { burnlistOvenHref, differentialTestingScenarioHref, legacyRoute, parseRoute, repoOvenHref } from "./route-model.mjs"; +export { BURNLIST_DATA_CONTRACT, fittingOvens, ovenFitsContract, ovenInRepoScope } from "./oven-fit.mjs"; export { adaptPerformanceTracingReport } from "./performance-tracing.mjs"; export { applyStreamingDiffUpdate, fileKindChip, groupStreamingDiffCard, isTextFileKind, mapStreamingDiffFeeds, mapStreamingDiffLandingFeeds, parseStreamingDiffCard, streamingDiffAutoOpenHref, streamingDiffFeedHref, streamingDiffFeedKey, streamingDiffRepositories } from "./streaming-diff.mjs"; export { visualParityDomainSummary } from "./visual-parity"; diff --git a/dashboard/src/lib/oven-fit.mjs b/dashboard/src/lib/oven-fit.mjs new file mode 100644 index 0000000..f4489ac --- /dev/null +++ b/dashboard/src/lib/oven-fit.mjs @@ -0,0 +1,13 @@ +export const BURNLIST_DATA_CONTRACT = "checklist-progress@1"; + +export function ovenFitsContract(oven, dataContract) { + return Boolean(oven) && oven.contract === dataContract; +} + +export function ovenInRepoScope(oven, repoKey) { + return oven?.repoKey == null || oven.repoKey === repoKey; +} + +export function fittingOvens(ovens, dataContract, { repoKey } = {}) { + return (Array.isArray(ovens) ? ovens : []).filter((oven) => ovenFitsContract(oven, dataContract) && ovenInRepoScope(oven, repoKey)); +} diff --git a/dashboard/src/lib/oven-fit.test.mjs b/dashboard/src/lib/oven-fit.test.mjs new file mode 100644 index 0000000..e5074e9 --- /dev/null +++ b/dashboard/src/lib/oven-fit.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { BURNLIST_DATA_CONTRACT, fittingOvens } from "./oven-fit.mjs"; + +test("fittingOvens selects contract-compatible Ovens in the burnlist repository scope", () => { + const ovens = [ + { id: "checklist", contract: "checklist-progress@1", repoKey: null, builtIn: true }, + { id: "differential-testing", contract: "burnlist-differential-testing-data@1", repoKey: null, builtIn: true }, + { id: "visual-parity", contract: "burnlist-visual-parity-data@1", repoKey: null, builtIn: true }, + { id: "streaming-diff", contract: "burnlist-streaming-diff-data@2", repoKey: null, builtIn: true }, + { id: "performance-tracing", contract: "burnlist-differential-testing-data@1", repoKey: null, builtIn: true }, + { id: "model-lab", contract: "burnlist-model-lab-data@1", repoKey: null, builtIn: true }, + { id: "release-readiness", contract: "checklist-progress@1", repoKey: "abc123", builtIn: false }, + { id: "other-repo-checklist", contract: "checklist-progress@1", repoKey: "zzz999", builtIn: false }, + ]; + + const selectedIds = new Set(fittingOvens(ovens, BURNLIST_DATA_CONTRACT, { repoKey: "abc123" }).map(({ id }) => id)); + + assert.equal(BURNLIST_DATA_CONTRACT, "checklist-progress@1"); + assert.deepEqual(selectedIds, new Set(["checklist", "release-readiness"])); + for (const id of ["differential-testing", "performance-tracing", "visual-parity", "streaming-diff", "model-lab", "other-repo-checklist"]) { + assert.equal(selectedIds.has(id), false); + } +}); diff --git a/dashboard/src/lib/route-model.mjs b/dashboard/src/lib/route-model.mjs index 1743317..bfd3424 100644 --- a/dashboard/src/lib/route-model.mjs +++ b/dashboard/src/lib/route-model.mjs @@ -81,6 +81,10 @@ export function burnlistHref({ repoKey, burnlistId, query } = {}) { return `/r/${encodeURIComponent(repoKey)}/${encodeURIComponent(burnlistId)}${queryString(query)}`; } +export function burnlistOvenHref({ repoKey, burnlistId, ovenId, query } = {}) { + return `/r/${encodeURIComponent(repoKey)}/${encodeURIComponent(burnlistId)}/o/${encodeURIComponent(ovenId)}${queryString(query)}`; +} + export function streamingDiffFeedHref({ repoKey, worktreeKey, session } = {}) { return repoOvenHref({ repoKey, ovenId: "streaming-diff", query: { worktreeKey, session } }); } diff --git a/dashboard/src/lib/route-model.test.mjs b/dashboard/src/lib/route-model.test.mjs index 3f1e05f..a16866d 100644 --- a/dashboard/src/lib/route-model.test.mjs +++ b/dashboard/src/lib/route-model.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { burnlistHref, + burnlistOvenHref, differentialTestingScenarioHref, legacyRoute, parseRoute, @@ -29,10 +30,12 @@ test("parseRoute recognizes the dashboard path model", () => { }); test("href builders put repo keys only in path segments", () => { + assert.equal(burnlistOvenHref({ repoKey: "k", burnlistId: "260721-001", ovenId: "checklist" }), "/r/k/260721-001/o/checklist"); const hrefs = [ repoOvenHref({ repoKey: "repo/key", ovenId: "custom oven", query: { filter: "active" } }), repoOvenHref({ repoKey: null, ovenId: "custom oven" }), burnlistHref({ repoKey: "repo/key", burnlistId: "burn/list", query: { filter: "active" } }), + burnlistOvenHref({ repoKey: "repo/key", burnlistId: "burn/list", ovenId: "custom oven", query: { filter: "active" } }), streamingDiffFeedHref({ repoKey: "repo/key", worktreeKey: "work tree", session: "session 1" }), differentialTestingScenarioHref({ repoKey: "repo/key", scenario: "case 1" }), ]; @@ -40,6 +43,7 @@ test("href builders put repo keys only in path segments", () => { "/r/repo%2Fkey/o/custom%20oven?filter=active", "/ovens/custom%20oven", "/r/repo%2Fkey/burn%2Flist?filter=active", + "/r/repo%2Fkey/burn%2Flist/o/custom%20oven?filter=active", "/r/repo%2Fkey/o/streaming-diff?worktreeKey=work+tree&session=session+1", "/r/repo%2Fkey/o/differential-testing?scenario=case+1", ]); diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index ab4bcca..439db73 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -62,6 +62,7 @@ export type Project = { export type OvenSummary = { id: string; + contract: string; name: string; description: string; builtIn: boolean; diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 271cd1a..6ec7b50 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -1,4 +1,5 @@ export const verificationTestFiles = [ + "dashboard/src/lib/oven-fit.test.mjs", "dashboard/src/lib/route-model.test.mjs", "src/server/dashboard-routes.test.mjs", "src/server/oven-event-routes.test.mjs", diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 2fa1296..62473a0 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -599,6 +599,7 @@ function findOven(id, selectedKey = null) { function ovenSummary(oven) { return { id: oven.id, + contract: oven.ir.contract, version: oven.ir.version, name: oven.name, description: oven.description, diff --git a/src/server/oven-routes.test.mjs b/src/server/oven-routes.test.mjs index 969daed..61f64bf 100644 --- a/src/server/oven-routes.test.mjs +++ b/src/server/oven-routes.test.mjs @@ -178,8 +178,9 @@ test("custom Ovens are identified by repository while built-ins remain global", assert.equal(new Set(shared.map((oven) => oven.repoKey)).size, 2); assert.deepEqual( Object.keys(shared[0]).sort(), - ["builtIn", "description", "id", "name", "ovenRevision", "repoKey", "version"], + ["builtIn", "contract", "description", "id", "name", "ovenRevision", "repoKey", "version"], ); + assert.equal(catalog.ovens.find((oven) => oven.id === "checklist").contract, "checklist-progress@1"); assert.equal(catalog.ovens.find((oven) => oven.id === "checklist").repoKey, null); assert.equal(catalog.ovens.find((oven) => oven.id === "differential-testing").repoKey, null); From 1b4f18e53059cd1c24d4daf140dba3ad0fb4c488 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 21:08:21 +0200 Subject: [PATCH 05/21] feat: oven catalog at /ovens with per-oven agent instructions and landing link (260721-001) --- dashboard/src/App.tsx | 10 +- .../components/OvenCatalog/OvenCatalog.css | 25 ++++ .../components/OvenCatalog/OvenCatalog.tsx | 118 ++++++++++++++++++ dashboard/src/components/OvenCatalog/index.ts | 1 + dashboard/src/components/index.ts | 1 + dashboard/src/index.css | 2 + dashboard/src/lib/index.ts | 1 + dashboard/src/lib/oven-catalog.mjs | 37 ++++++ dashboard/src/lib/oven-catalog.test.mjs | 70 +++++++++++ dashboard/src/lib/types.ts | 1 + scripts/verify-test-files.mjs | 1 + 11 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 dashboard/src/components/OvenCatalog/OvenCatalog.css create mode 100644 dashboard/src/components/OvenCatalog/OvenCatalog.tsx create mode 100644 dashboard/src/components/OvenCatalog/index.ts create mode 100644 dashboard/src/lib/oven-catalog.mjs create mode 100644 dashboard/src/lib/oven-catalog.test.mjs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 8e225e7..fadbc6f 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,9 +1,10 @@ import { useMemo, useState } from "react"; import { ListChecks } from "lucide-react"; -import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; +import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, OvenCatalog, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; import { useDashboardData } from "@hooks"; import { currentSection, filterFromUrl, selectedBurnlist } from "@lib"; import type { Filter } from "@lib"; +import { Button } from "@layout"; export function App() { const section = currentSection(); @@ -29,7 +30,7 @@ export function App() {
- {section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ?

Ovens

Oven catalog coming soon.

: section === "oven-explainer" ?

Oven

Oven details coming soon.

: selected ? ( + {section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ? : section === "oven-explainer" ?

Oven

Oven details coming soon.

: selected ? ( error ? : loading && !progress ? : progress ? ( <> ) : @@ -40,7 +41,10 @@ export function App() {

Burnlists

{visibleBurnlistCount} Burnlists in {visibleProjectCount} {visibleProjectCount === 1 ? "project" : "projects"}

- +
+ + +
{error ? : projects.length && visibleBurnlistCount ? (
{projects.map((project) => )}
diff --git a/dashboard/src/components/OvenCatalog/OvenCatalog.css b/dashboard/src/components/OvenCatalog/OvenCatalog.css new file mode 100644 index 0000000..25fe3a6 --- /dev/null +++ b/dashboard/src/components/OvenCatalog/OvenCatalog.css @@ -0,0 +1,25 @@ +.oven-catalog { gap: 20px; } + +.oven-catalog-heading { display: grid; gap: 3px; padding: 2px 0 4px; } +.oven-catalog-list { display: grid; gap: 12px; } +.oven-catalog-card { gap: 16px; border-color: rgba(232, 232, 232, .1); } +.oven-catalog-card-header { gap: 6px; } +.oven-catalog-name { margin: 0; font: inherit; } +.oven-catalog-label { font-family: var(--dashboard-font); font-variant-numeric: tabular-nums; } +.oven-catalog-card-content { display: grid; gap: 14px; } +.oven-catalog-badges { display: flex; flex-wrap: wrap; gap: 6px; } +.oven-catalog-description { margin: 0; color: var(--muted-foreground); line-height: 1.5; } +.oven-catalog-explainer-link { width: fit-content; color: var(--primary); font-family: var(--dashboard-title-font); } +.oven-catalog-explainer-link:hover { text-decoration: underline; text-underline-offset: 4px; } +.oven-catalog-explainer-link:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } + +.oven-catalog-agent-block { overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: #090909; } +.oven-catalog-agent-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-bottom: 1px solid var(--border); } +.oven-catalog-agent-heading h3 { margin: 0; font: 400 13px/1.2 var(--dashboard-title-font); color: var(--muted-foreground); } +.oven-catalog-agent-block pre { margin: 0; padding: 12px; overflow-x: auto; color: var(--foreground); font: 13px/1.55 var(--dashboard-font); white-space: pre-wrap; } + +.copy-btn { display: inline-flex; align-items: center; gap: 5px; min-height: 26px; padding: 4px 7px; border: 1px solid var(--border); border-radius: 5px; color: var(--muted-foreground); cursor: pointer; } +.copy-btn:hover { border-color: #3a3a3a; background: var(--accent); color: var(--foreground); } +.copy-btn:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.copy-btn svg { width: 14px; height: 14px; } +.copy-btn span { font: 12px/1 var(--dashboard-title-font); } diff --git a/dashboard/src/components/OvenCatalog/OvenCatalog.tsx b/dashboard/src/components/OvenCatalog/OvenCatalog.tsx new file mode 100644 index 0000000..0393fcf --- /dev/null +++ b/dashboard/src/components/OvenCatalog/OvenCatalog.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { buildOvenCatalog } from "@lib"; +import type { OvenSummary } from "@lib/types"; +import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@layout"; +import { DashboardError } from "../DashboardError"; +import { EmptyState } from "../EmptyState"; +import "./OvenCatalog.css"; + +type OvenCatalogEntry = { + id: string; + name: string; + version: string; + contract: string; + description: string; + builtIn: boolean; + repoKey: string | null; + label: string; + href: string; + adoptCommand: string; + agentInstructions: string; +}; + +function CopyButton({ text }: { text: string }) { + const [isCopied, setIsCopied] = useState(false); + const resetTimer = useRef>(); + + useEffect(() => () => clearTimeout(resetTimer.current), []); + + const copy = async () => { + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard; + if (!clipboard?.writeText) return; + try { + await clipboard.writeText(text); + setIsCopied(true); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setIsCopied(false), 1500); + } catch { + // Clipboard access may be unavailable outside a secure browser context. + } + }; + + return ( + + ); +} + +export function OvenCatalog() { + const [catalog, setCatalog] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + const load = async () => { + setCatalog(null); + setError(""); + try { + const response = await fetch("/api/ovens", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error(`Could not load Ovens (${response.status}).`); + const payload = await response.json() as { ovens?: OvenSummary[] }; + if (controller.signal.aborted) return; + setCatalog(buildOvenCatalog(payload.ovens) as OvenCatalogEntry[]); + } catch (cause) { + if (controller.signal.aborted) return; + setError(cause instanceof Error ? cause.message : "Could not load Ovens."); + } + }; + void load(); + return () => controller.abort(); + }, []); + + if (error) return ; + if (!catalog) return ; + + return ( +
+
+

Ovens

+

{catalog.length} {catalog.length === 1 ? "Oven" : "Ovens"}

+
+
+ {catalog.map((entry) => ( + + +

{entry.name}

+ {entry.label} +
+ +
+ Contract: {entry.contract} + {entry.builtIn ? "Built-in" : "Custom"} + {!entry.builtIn && entry.repoKey ? {entry.repoKey} : null} +
+

{entry.description}

+ Open Oven explainer +
+
+

Tell your agent

+ +
+
{entry.agentInstructions}
+
+
+
+ ))} +
+
+ ); +} diff --git a/dashboard/src/components/OvenCatalog/index.ts b/dashboard/src/components/OvenCatalog/index.ts new file mode 100644 index 0000000..4e48a59 --- /dev/null +++ b/dashboard/src/components/OvenCatalog/index.ts @@ -0,0 +1 @@ +export { OvenCatalog } from "./OvenCatalog"; diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts index af5ddd9..9ba510f 100644 --- a/dashboard/src/components/index.ts +++ b/dashboard/src/components/index.ts @@ -8,6 +8,7 @@ export { EmptyState } from "./EmptyState"; export { FILTERS, Filters } from "./Filters"; export { LensSwitcher } from "./LensSwitcher"; export { ModelLabPage } from "./ModelLab"; +export { OvenCatalog } from "./OvenCatalog"; export { BurnlistTable, ProjectGroup } from "./ProjectGroup"; export { StreamingDiff } from "./StreamingDiff"; export { VisualParityPage } from "./VisualParity"; diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 6af2379..e41967b 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -247,6 +247,7 @@ h1, h2, h3, h4, h5, h6, strong, b, th { font-weight: 400; } .dashboard-index-heading { display: grid; gap: 3px; } .dashboard-index-title { margin: 0; font: 400 22px/1.2 var(--dashboard-title-font); letter-spacing: -.01em; } .dashboard-index-summary { margin: 0; color: rgba(168, 168, 168, .62); font-size: 14px; } +.dashboard-index-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 12px; } .dashboard-filters { flex: 0 0 auto; } .dashboard-empty-state { display: grid; min-height: 288px; padding: 0 24px; place-items: center; text-align: center; } @@ -681,6 +682,7 @@ body.checklist-detail-view .dashboard-oven-title { .event-card-description { padding: 10px; } .dashboard-main[data-layout="index"] { padding-inline: 12px; } .dashboard-index-header { align-items: flex-start; flex-direction: column; } + .dashboard-index-actions { width: 100%; align-items: flex-start; flex-direction: column; } .dashboard-filters { width: 100%; overflow-x: auto; } .burnlist-table[data-show-status] .burnlist-table-column-project { width: 27%; } .burnlist-table[data-show-status] .burnlist-table-column-primary { width: 48%; } diff --git a/dashboard/src/lib/index.ts b/dashboard/src/lib/index.ts index be4f275..6f8910d 100644 --- a/dashboard/src/lib/index.ts +++ b/dashboard/src/lib/index.ts @@ -2,6 +2,7 @@ export { formatListTime, formatTime } from "./format"; export { burnlistHref, burnlistLensContext, currentSection, customOvenSelection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist, streamingDiffSelection } from "./hrefs"; export { burnlistOvenHref, differentialTestingScenarioHref, legacyRoute, parseRoute, repoOvenHref } from "./route-model.mjs"; export { BURNLIST_DATA_CONTRACT, fittingOvens, ovenFitsContract, ovenInRepoScope } from "./oven-fit.mjs"; +export { buildOvenCatalog } from "./oven-catalog.mjs"; export { adaptPerformanceTracingReport } from "./performance-tracing.mjs"; export { applyStreamingDiffUpdate, fileKindChip, groupStreamingDiffCard, isTextFileKind, mapStreamingDiffFeeds, mapStreamingDiffLandingFeeds, parseStreamingDiffCard, streamingDiffAutoOpenHref, streamingDiffFeedHref, streamingDiffFeedKey, streamingDiffRepositories } from "./streaming-diff.mjs"; export { visualParityDomainSummary } from "./visual-parity"; diff --git a/dashboard/src/lib/oven-catalog.mjs b/dashboard/src/lib/oven-catalog.mjs new file mode 100644 index 0000000..c99e043 --- /dev/null +++ b/dashboard/src/lib/oven-catalog.mjs @@ -0,0 +1,37 @@ +function agentInstructions(oven) { + const label = `${oven.id}@${oven.version}`; + return [ + `Use the ${oven.name} Oven (${label}).`, + `Its data must satisfy the ${oven.contract} contract.`, + "Adopt the Oven before preparing its data:", + `burnlist oven adopt ${oven.id}`, + "Produce the required data, then bind it to the target path:", + `burnlist oven bind ${oven.id} `, + ].join("\n"); +} + +function compareOvens(left, right) { + if (left.builtIn !== right.builtIn) return left.builtIn ? -1 : 1; + return left.name.localeCompare(right.name, undefined, { sensitivity: "base" }); +} + +export function buildOvenCatalog(ovens) { + return (Array.isArray(ovens) ? ovens : []) + .map((oven) => { + const repoQuery = oven.repoKey == null ? "" : `?repoKey=${encodeURIComponent(oven.repoKey)}`; + return { + id: oven.id, + name: oven.name, + version: oven.version, + contract: oven.contract, + description: oven.description, + builtIn: oven.builtIn, + repoKey: oven.repoKey, + label: `${oven.id}@${oven.version}`, + href: `/ovens/${encodeURIComponent(oven.id)}${repoQuery}`, + adoptCommand: `burnlist oven adopt ${oven.id}`, + agentInstructions: agentInstructions(oven), + }; + }) + .sort(compareOvens); +} diff --git a/dashboard/src/lib/oven-catalog.test.mjs b/dashboard/src/lib/oven-catalog.test.mjs new file mode 100644 index 0000000..374206b --- /dev/null +++ b/dashboard/src/lib/oven-catalog.test.mjs @@ -0,0 +1,70 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildOvenCatalog } from './oven-catalog.mjs'; + +test('buildOvenCatalog builds sorted catalog entries with adoption instructions', () => { + const ovens = [ + { + id: 'custom/experiment', + contract: 'custom-contract-v2', + version: '2.4.0', + name: 'Workshop Oven', + description: 'A repository-specific custom oven.', + builtIn: false, + repoKey: 'acme/widgets', + }, + { + id: 'beta', + contract: 'builtin-contract-v1', + version: '1.1.0', + name: 'beta Oven', + description: 'The second built-in oven.', + builtIn: true, + repoKey: null, + }, + { + id: 'alpha', + contract: 'builtin-contract-v1', + version: '1.0.0', + name: 'Alpha Oven', + description: 'The first built-in oven.', + builtIn: true, + repoKey: null, + }, + ]; + + const catalog = buildOvenCatalog(ovens); + + assert.equal(catalog.length, ovens.length); + assert.deepEqual( + catalog.map(({ id }) => id), + ['alpha', 'beta', 'custom/experiment'], + ); + assert.ok(catalog.slice(0, 2).every(({ builtIn }) => builtIn)); + assert.ok(catalog.slice(2).every(({ builtIn }) => !builtIn)); + + for (const entry of catalog) { + const oven = ovens.find(({ id }) => id === entry.id); + + assert.equal(entry.name, oven.name); + assert.equal(entry.version, oven.version); + assert.equal(entry.contract, oven.contract); + assert.equal(entry.description, oven.description); + assert.equal(entry.builtIn, oven.builtIn); + assert.equal(entry.repoKey, oven.repoKey); + assert.equal(entry.label, `${oven.id}@${oven.version}`); + assert.equal(entry.adoptCommand, `burnlist oven adopt ${oven.id}`); + assert.ok(entry.agentInstructions.includes(oven.name)); + assert.ok(entry.agentInstructions.includes(entry.label)); + assert.ok(entry.agentInstructions.includes(oven.contract)); + assert.ok(entry.agentInstructions.includes(`burnlist oven adopt ${oven.id}`)); + assert.ok(entry.agentInstructions.includes(`burnlist oven bind ${oven.id}`)); + } + + const alpha = catalog.find(({ id }) => id === 'alpha'); + const custom = catalog.find(({ id }) => id === 'custom/experiment'); + + assert.equal(alpha.href, '/ovens/alpha'); + assert.equal(custom.href, '/ovens/custom%2Fexperiment?repoKey=acme%2Fwidgets'); +}); diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 439db73..8009d07 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -63,6 +63,7 @@ export type Project = { export type OvenSummary = { id: string; contract: string; + version: string; name: string; description: string; builtIn: boolean; diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 6ec7b50..f2f4cf4 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -1,5 +1,6 @@ export const verificationTestFiles = [ "dashboard/src/lib/oven-fit.test.mjs", + "dashboard/src/lib/oven-catalog.test.mjs", "dashboard/src/lib/route-model.test.mjs", "src/server/dashboard-routes.test.mjs", "src/server/oven-event-routes.test.mjs", From 3dd9fa95097b5e16fc27eac5a9e0c1759f1f182b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 21:23:10 +0200 Subject: [PATCH 06/21] feat: oven explainer page at /ovens/ with docs and sample-data demo render (260721-001) --- dashboard/src/App.tsx | 4 +- .../src/components/CopyButton/CopyButton.tsx | 35 ++++++ dashboard/src/components/CopyButton/index.ts | 1 + .../components/OvenCatalog/OvenCatalog.tsx | 37 +------ .../OvenExplainer/OvenExplainer.css | 22 ++++ .../OvenExplainer/OvenExplainer.tsx | 73 +++++++++++++ .../OvenExplainer/OvenExplainerView.tsx | 72 ++++++++++++ .../src/components/OvenExplainer/index.ts | 1 + .../oven-explainer-render.test.mjs | 103 ++++++++++++++++++ dashboard/src/components/index.ts | 1 + dashboard/src/lib/hrefs.ts | 7 ++ dashboard/src/lib/index.ts | 3 +- dashboard/src/lib/oven-samples.mjs | 29 +++++ scripts/verify-test-files.mjs | 1 + 14 files changed, 351 insertions(+), 38 deletions(-) create mode 100644 dashboard/src/components/CopyButton/CopyButton.tsx create mode 100644 dashboard/src/components/CopyButton/index.ts create mode 100644 dashboard/src/components/OvenExplainer/OvenExplainer.css create mode 100644 dashboard/src/components/OvenExplainer/OvenExplainer.tsx create mode 100644 dashboard/src/components/OvenExplainer/OvenExplainerView.tsx create mode 100644 dashboard/src/components/OvenExplainer/index.ts create mode 100644 dashboard/src/components/OvenExplainer/oven-explainer-render.test.mjs create mode 100644 dashboard/src/lib/oven-samples.mjs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index fadbc6f..29b3280 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { ListChecks } from "lucide-react"; -import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, OvenCatalog, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; +import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, OvenCatalog, OvenExplainer, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; import { useDashboardData } from "@hooks"; import { currentSection, filterFromUrl, selectedBurnlist } from "@lib"; import type { Filter } from "@lib"; @@ -30,7 +30,7 @@ export function App() {
- {section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ? : section === "oven-explainer" ?

Oven

Oven details coming soon.

: selected ? ( + {section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ? : section === "oven-explainer" ? : selected ? ( error ? : loading && !progress ? : progress ? ( <> ) : diff --git a/dashboard/src/components/CopyButton/CopyButton.tsx b/dashboard/src/components/CopyButton/CopyButton.tsx new file mode 100644 index 0000000..b3e1001 --- /dev/null +++ b/dashboard/src/components/CopyButton/CopyButton.tsx @@ -0,0 +1,35 @@ +import { useEffect, useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; + +export function CopyButton({ text }: { text: string }) { + const [isCopied, setIsCopied] = useState(false); + const resetTimer = useRef>(); + + useEffect(() => () => clearTimeout(resetTimer.current), []); + + const copy = async () => { + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard; + if (!clipboard?.writeText) return; + try { + await clipboard.writeText(text); + setIsCopied(true); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setIsCopied(false), 1500); + } catch { + // Clipboard access may be unavailable outside a secure browser context. + } + }; + + return ( + + ); +} diff --git a/dashboard/src/components/CopyButton/index.ts b/dashboard/src/components/CopyButton/index.ts new file mode 100644 index 0000000..10bb060 --- /dev/null +++ b/dashboard/src/components/CopyButton/index.ts @@ -0,0 +1 @@ +export { CopyButton } from "./CopyButton"; diff --git a/dashboard/src/components/OvenCatalog/OvenCatalog.tsx b/dashboard/src/components/OvenCatalog/OvenCatalog.tsx index 0393fcf..7450494 100644 --- a/dashboard/src/components/OvenCatalog/OvenCatalog.tsx +++ b/dashboard/src/components/OvenCatalog/OvenCatalog.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState } from "react"; -import { Check, Copy } from "lucide-react"; +import { useEffect, useState } from "react"; import { buildOvenCatalog } from "@lib"; import type { OvenSummary } from "@lib/types"; import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@layout"; import { DashboardError } from "../DashboardError"; +import { CopyButton } from "../CopyButton"; import { EmptyState } from "../EmptyState"; import "./OvenCatalog.css"; @@ -21,39 +21,6 @@ type OvenCatalogEntry = { agentInstructions: string; }; -function CopyButton({ text }: { text: string }) { - const [isCopied, setIsCopied] = useState(false); - const resetTimer = useRef>(); - - useEffect(() => () => clearTimeout(resetTimer.current), []); - - const copy = async () => { - const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard; - if (!clipboard?.writeText) return; - try { - await clipboard.writeText(text); - setIsCopied(true); - clearTimeout(resetTimer.current); - resetTimer.current = setTimeout(() => setIsCopied(false), 1500); - } catch { - // Clipboard access may be unavailable outside a secure browser context. - } - }; - - return ( - - ); -} - export function OvenCatalog() { const [catalog, setCatalog] = useState(null); const [error, setError] = useState(""); diff --git a/dashboard/src/components/OvenExplainer/OvenExplainer.css b/dashboard/src/components/OvenExplainer/OvenExplainer.css new file mode 100644 index 0000000..6bfdf64 --- /dev/null +++ b/dashboard/src/components/OvenExplainer/OvenExplainer.css @@ -0,0 +1,22 @@ +.oven-explainer { gap: 20px; } +.oven-explainer-heading { display: grid; gap: 9px; padding: 2px 0 4px; } +.oven-explainer-badges { display: flex; flex-wrap: wrap; gap: 6px; } +.oven-explainer-note { max-width: 720px; margin: 0; color: var(--muted-foreground); line-height: 1.5; } +.oven-explainer-note code, .oven-explainer-data-shape code { color: var(--foreground); font-family: var(--dashboard-font); } +.oven-explainer-card { gap: 14px; border-color: rgba(232, 232, 232, .1); } +.oven-explainer-card-content { display: grid; gap: 16px; } +.oven-explainer-data-shape { display: grid; gap: 6px; } +.oven-explainer-data-shape h3 { margin: 0; font: 400 14px/1.2 var(--dashboard-title-font); } +.oven-explainer-data-shape p, .oven-explainer-docs-only { margin: 0; color: var(--muted-foreground); line-height: 1.5; } + +.oven-explainer-agent-block { overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: #090909; } +.oven-explainer-agent-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-bottom: 1px solid var(--border); } +.oven-explainer-agent-heading h3 { margin: 0; font: 400 13px/1.2 var(--dashboard-title-font); color: var(--muted-foreground); } +.oven-explainer-agent-block pre { margin: 0; padding: 12px; overflow-x: auto; color: var(--foreground); font: 13px/1.55 var(--dashboard-font); white-space: pre-wrap; } +.oven-explainer .copy-btn { display: inline-flex; align-items: center; gap: 5px; min-height: 26px; padding: 4px 7px; border: 1px solid var(--border); border-radius: 5px; color: var(--muted-foreground); cursor: pointer; } +.oven-explainer .copy-btn:hover { border-color: #3a3a3a; background: var(--accent); color: var(--foreground); } +.oven-explainer .copy-btn:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.oven-explainer .copy-btn svg { width: 14px; height: 14px; } +.oven-explainer .copy-btn span { font: 12px/1 var(--dashboard-title-font); } + +.oven-explainer-demo-runtime { overflow: auto; max-height: 760px; padding: 12px; border: 1px solid var(--border); border-radius: 6px; background: #090909; } diff --git a/dashboard/src/components/OvenExplainer/OvenExplainer.tsx b/dashboard/src/components/OvenExplainer/OvenExplainer.tsx new file mode 100644 index 0000000..7af83c4 --- /dev/null +++ b/dashboard/src/components/OvenExplainer/OvenExplainer.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState, type ComponentProps } from "react"; +import { buildOvenCatalog, ovenExplainerSelection, ovenSamplePayload } from "@lib"; +import type { OvenSummary } from "@lib/types"; +import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; +import { DashboardError } from "../DashboardError"; +import { EmptyState } from "../EmptyState"; +import { OvenExplainerView } from "./OvenExplainerView"; + +type OvenCatalogEntry = { + id: string; + name: string; + version: string; + contract: string; + description: string; + builtIn: boolean; + repoKey: string | null; + label: string; + href: string; + adoptCommand: string; + agentInstructions: string; +}; +type OvenIr = ComponentProps["ir"]; +type LoadedOven = { entry: OvenCatalogEntry; ir: OvenIr | null }; + +export function OvenExplainer() { + const selection = ovenExplainerSelection(); + const [loaded, setLoaded] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + if (!selection) { + setLoaded(null); + setError("This Oven explainer requires an Oven identifier."); + return; + } + const controller = new AbortController(); + const load = async () => { + setLoaded(null); + setError(""); + try { + const catalogResponse = await fetch("/api/ovens", { cache: "no-store", signal: controller.signal }); + if (!catalogResponse.ok) throw new Error(`Could not load Ovens (${catalogResponse.status}).`); + const catalogBody = await catalogResponse.json() as { ovens?: OvenSummary[] }; + if (controller.signal.aborted) return; + const entry = (buildOvenCatalog(catalogBody.ovens) as OvenCatalogEntry[]).find((candidate) => candidate.id === selection.ovenId + && candidate.repoKey === selection.repoKey); + if (!entry) throw new Error("This Oven could not be found."); + + const query = entry.repoKey ? `?repoKey=${encodeURIComponent(entry.repoKey)}` : ""; + try { + const ovenResponse = await fetch(`/api/ovens/${encodeURIComponent(selection.ovenId)}${query}`, { cache: "no-store", signal: controller.signal }); + if (!ovenResponse.ok) throw new Error(`Could not load Oven (${ovenResponse.status}).`); + const ovenBody = await ovenResponse.json() as { oven?: { ir?: OvenIr } }; + if (controller.signal.aborted) return; + setLoaded({ entry, ir: ovenBody.oven?.ir ?? null }); + } catch { + if (controller.signal.aborted) return; + setLoaded({ entry, ir: null }); + } + } catch (cause) { + if (controller.signal.aborted) return; + setError(cause instanceof Error ? cause.message : "Could not load this Oven."); + } + }; + void load(); + return () => controller.abort(); + }, [selection?.ovenId, selection?.repoKey]); + + if (error) return ; + if (!loaded) return ; + if (!loaded.ir) return ; + return ; +} diff --git a/dashboard/src/components/OvenExplainer/OvenExplainerView.tsx b/dashboard/src/components/OvenExplainer/OvenExplainerView.tsx new file mode 100644 index 0000000..c0462b2 --- /dev/null +++ b/dashboard/src/components/OvenExplainer/OvenExplainerView.tsx @@ -0,0 +1,72 @@ +import type { ComponentProps } from "react"; +import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@layout"; +import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; +import { CopyButton } from "../CopyButton"; +import "./OvenExplainer.css"; + +type OvenCatalogEntry = { + id: string; + name: string; + version: string; + contract: string; + description: string; + builtIn: boolean; + repoKey: string | null; + label: string; + href: string; + adoptCommand: string; + agentInstructions: string; +}; + +type OvenIr = ComponentProps["ir"]; + +export function OvenExplainerView({ entry, ir, sample }: { entry: OvenCatalogEntry; ir: OvenIr; sample: unknown | null }) { + return ( +
+
+

{entry.name}

+

{entry.label}

+
+ Contract: {entry.contract} + {entry.builtIn ? "Built-in" : "Custom"} + {!entry.builtIn && entry.repoKey ? {entry.repoKey} : null} +
+

This is the Oven explainer. The live bound view lives at the scoped /r/…/o/{entry.id} URL.

+
+ + + +

Docs

+ {entry.description} +
+ +
+

What it shows / data shape

+

This Oven renders data that satisfies the {entry.contract} contract.

+
+
+
+

Tell your agent

+ +
+
{entry.agentInstructions}
+
+
+
+ + + +

Demo (sample data)

+ A static preview that never requests or changes live Oven data. +
+ + {sample !== null ? ( +
+ +
+ ) :

This Oven needs live data, so no sample data demo is available.

} +
+
+
+ ); +} diff --git a/dashboard/src/components/OvenExplainer/index.ts b/dashboard/src/components/OvenExplainer/index.ts new file mode 100644 index 0000000..0ccc1f8 --- /dev/null +++ b/dashboard/src/components/OvenExplainer/index.ts @@ -0,0 +1 @@ +export { OvenExplainer } from "./OvenExplainer"; diff --git a/dashboard/src/components/OvenExplainer/oven-explainer-render.test.mjs b/dashboard/src/components/OvenExplainer/oven-explainer-render.test.mjs new file mode 100644 index 0000000..21dce24 --- /dev/null +++ b/dashboard/src/components/OvenExplainer/oven-explainer-render.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +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 { compileOven } from "../../../../src/ovens/dsl/oven-compile.mjs"; + +const explainerPath = new URL("../OvenExplainer/OvenExplainerView.tsx", import.meta.url).pathname; +const sourceDir = new URL("../../", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("../../oven", import.meta.url).pathname; +const ovenSource = ` + + + +`; +const entry = { + id: "widget-oven", + name: "Widget Oven", + version: "0.1.0", + contract: "checklist-progress@1", + description: "Shows widget progress.", + builtIn: true, + repoKey: null, + label: "widget-oven@0.1.0", + href: "/ovens/widget-oven", + adoptCommand: "burnlist oven adopt widget-oven", + agentInstructions: "Use the Widget Oven Oven (widget-oven@0.1.0).\nIts data must satisfy the checklist-progress@1 contract.\nAdopt the Oven before preparing its data:\nburnlist oven adopt widget-oven\nProduce the required data, then bind it to the target path:\nburnlist oven bind widget-oven ", +}; +const sample = { widget: { name: "Sprockets", count: 42 } }; + +test("an Oven explainer renders catalog details and its sample-data demo", { timeout: 20_000 }, async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".oven-explainer-render-test-")); + try { + const explainerOutput = join(outputDir, "OvenExplainerView.mjs"); + await build({ + entryPoints: [explainerPath], + bundle: true, + format: "esm", + outfile: explainerOutput, + platform: "node", + alias: { "@": sourceDir, "@lib": libPath, "@oven": ovenPath }, + jsx: "automatic", + packages: "external", + target: "node18", + }); + const { OvenExplainerView } = await import(`${new URL(`file://${explainerOutput}`).href}?test=${Date.now()}`); + const compiled = compileOven(ovenSource, { file: "widget-oven.oven" }); + assert.equal(compiled.ok, true, compiled.ok ? "" : JSON.stringify(compiled.diagnostics)); + if (!compiled.ok) return; + + const markup = renderToStaticMarkup(createElement(OvenExplainerView, { + entry, + ir: compiled.ir, + sample, + })); + assert.match(markup, /Widget Oven/u); + assert.match(markup, /widget-oven@0\.0*1\.0/u); + assert.match(markup, /checklist-progress@1/u); + assert.match(markup, /Tell your agent/iu); + assert.match(markup, /burnlist oven adopt widget-oven/u); + assert.match(markup, /Demo \(sample data\)/iu); + assert.match(markup, /Sprockets/u); + assert.match(markup, />42 { + const outputDir = await mkdtemp(join(process.cwd(), ".oven-explainer-render-test-")); + try { + const explainerOutput = join(outputDir, "OvenExplainerView.mjs"); + await build({ + entryPoints: [explainerPath], + bundle: true, + format: "esm", + outfile: explainerOutput, + platform: "node", + alias: { "@": sourceDir, "@lib": libPath, "@oven": ovenPath }, + jsx: "automatic", + packages: "external", + target: "node18", + }); + const { OvenExplainerView } = await import(`${new URL(`file://${explainerOutput}`).href}?test=${Date.now()}`); + const compiled = compileOven(ovenSource, { file: "widget-oven.oven" }); + assert.equal(compiled.ok, true, compiled.ok ? "" : JSON.stringify(compiled.diagnostics)); + if (!compiled.ok) return; + + const markup = renderToStaticMarkup(createElement(OvenExplainerView, { + entry, + ir: compiled.ir, + sample: null, + })); + assert.doesNotMatch(markup, /Sprockets/u); + assert.match(markup, /Demo \(sample data\)/iu); + assert.match(markup, /sample data|unavailable|not available|requires/iu); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } +}); diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts index 9ba510f..e5961c0 100644 --- a/dashboard/src/components/index.ts +++ b/dashboard/src/components/index.ts @@ -9,6 +9,7 @@ export { FILTERS, Filters } from "./Filters"; export { LensSwitcher } from "./LensSwitcher"; export { ModelLabPage } from "./ModelLab"; export { OvenCatalog } from "./OvenCatalog"; +export { OvenExplainer } from "./OvenExplainer"; export { BurnlistTable, ProjectGroup } from "./ProjectGroup"; export { StreamingDiff } from "./StreamingDiff"; export { VisualParityPage } from "./VisualParity"; diff --git a/dashboard/src/lib/hrefs.ts b/dashboard/src/lib/hrefs.ts index edd048f..5bfa532 100644 --- a/dashboard/src/lib/hrefs.ts +++ b/dashboard/src/lib/hrefs.ts @@ -14,6 +14,13 @@ export function customOvenSelection(): { id: string; repoKey: string | null; bur return current.section === "custom-oven" ? { id: current.ovenId, repoKey: current.repoKey, burnlistId: current.burnlistId ?? null } : null; } +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") } + : null; +} + export function burnlistLensContext(): { repoKey: string; burnlistId: string; activeOvenId: string } | null { const current = route(); if (!current.repoKey || !current.burnlistId) return null; diff --git a/dashboard/src/lib/index.ts b/dashboard/src/lib/index.ts index 6f8910d..0132b27 100644 --- a/dashboard/src/lib/index.ts +++ b/dashboard/src/lib/index.ts @@ -1,8 +1,9 @@ export { formatListTime, formatTime } from "./format"; -export { burnlistHref, burnlistLensContext, currentSection, customOvenSelection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist, streamingDiffSelection } from "./hrefs"; +export { burnlistHref, burnlistLensContext, currentSection, customOvenSelection, filterFromUrl, listHref, ovenExplainerSelection, ovenRepoKey, selectedBurnlist, streamingDiffSelection } from "./hrefs"; export { burnlistOvenHref, differentialTestingScenarioHref, legacyRoute, parseRoute, repoOvenHref } from "./route-model.mjs"; export { BURNLIST_DATA_CONTRACT, fittingOvens, ovenFitsContract, ovenInRepoScope } from "./oven-fit.mjs"; export { buildOvenCatalog } from "./oven-catalog.mjs"; +export { ovenSamplePayload } from "./oven-samples.mjs"; export { adaptPerformanceTracingReport } from "./performance-tracing.mjs"; export { applyStreamingDiffUpdate, fileKindChip, groupStreamingDiffCard, isTextFileKind, mapStreamingDiffFeeds, mapStreamingDiffLandingFeeds, parseStreamingDiffCard, streamingDiffAutoOpenHref, streamingDiffFeedHref, streamingDiffFeedKey, streamingDiffRepositories } from "./streaming-diff.mjs"; export { visualParityDomainSummary } from "./visual-parity"; diff --git a/dashboard/src/lib/oven-samples.mjs b/dashboard/src/lib/oven-samples.mjs new file mode 100644 index 0000000..9539353 --- /dev/null +++ b/dashboard/src/lib/oven-samples.mjs @@ -0,0 +1,29 @@ +import { adaptChecklist } from "./checklist-adapter"; + +const checklistSample = { + generatedAt: "2026-07-21T10:00:00Z", + repoKey: null, + repo: "sample-repository", + planLabel: "burnlist.md", + title: "Sample Burnlist", + total: 3, + done: 2, + remaining: 1, + percent: 67, + warnings: [], + active: [{ id: "B6", title: "Explain the Oven", fields: {} }], + completed: [ + { id: "B4", title: "Add the catalog route", completedAt: "2026-07-21T09:20:00Z", detail: "Outcome: Catalog route is available." }, + { id: "B5", title: "Prepare Oven definitions", completedAt: "2026-07-21T09:40:00Z", detail: "Outcome: Definitions are ready to inspect." }, + ], + history: [ + { time: "2026-07-21T09:20:00Z", done: 1, remaining: 2, total: 3, percent: 33 }, + { time: "2026-07-21T09:40:00Z", done: 2, remaining: 1, total: 3, percent: 67 }, + { time: "2026-07-21T10:00:00Z", done: 2, remaining: 1, total: 3, percent: 67 }, + ], +}; + +/** Returns static demo data only when an Oven can render without live data. */ +export function ovenSamplePayload(ovenId) { + return ovenId === "checklist" ? adaptChecklist(checklistSample) : null; +} diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index f2f4cf4..3a48709 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -88,6 +88,7 @@ export const verificationTestFiles = [ "dashboard/src/lib/streaming-diff.test.mjs", "dashboard/src/lib/project-open.test.mjs", "dashboard/src/lib/oven-identity.test.mjs", + "dashboard/src/components/OvenExplainer/oven-explainer-render.test.mjs", ]; // These subprocess wall-clock assertions must not compete with the parallel From 9e00a058ba70d01fe064890107862bbd9843b414 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 22:31:31 +0200 Subject: [PATCH 07/21] docs: document oven versioning, adopt/upgrade pinning, and path-based dashboard URLs in skills --- skills/burnlist/SKILL.md | 2 +- .../burnlist/references/burnlist-dashboard.md | 37 +++++++++++++ skills/burnlist/references/creating-ovens.md | 10 +++- skills/burnlist/references/designing-ovens.md | 5 ++ skills/burnlist/references/oven-authoring.md | 52 +++++++++++++++++-- 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index dada6aa..d6b8b6d 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -135,7 +135,7 @@ A burnlist in an unregistered repo is still visible when the dashboard is launch `New Oven` and `Run Burn` are explicit user-controlled local controller surfaces. For Oven contract, UI, validation, or Run-snapshot work, read `references/oven-contract.md`. Preserve its two-file declarative package and ownership boundary: custom Ovens may be created under ignored `.local/burnlist/ovens/` state and snapshotted under `.local/burnlist/runs/`, but neither surface may execute instructions, produce project data, own canonical project state, mutate Burnlists, import arbitrary UI code, or start an agent. -Ovens can also be authored and inspected from the CLI: `burnlist oven `. It writes only custom Ovens, keeps built-in Ovens read-only, reuses the same contract validation, and never executes instructions. `burnlist oven view ` renders the detail skeleton as a box-drawing grid for quick inspection. Read `references/oven-authoring.md` for the widget/format vocabulary and source-binding conventions. +Ovens can also be authored and inspected from the CLI: `burnlist oven `. It writes custom Ovens or committed vendored copies, keeps shipped built-in Ovens read-only, reuses the same contract validation, and never executes instructions. Ovens carry an `id@version` identity and can be vendored and pinned per project with `adopt` and opt-in `upgrade`. `burnlist oven view ` renders the detail skeleton as a box-drawing grid for quick inspection. Read `references/oven-authoring.md` for the widget/format vocabulary and source-binding conventions. Oven progress events are a separate observational surface under ignored repo-local state. They never replace Burnlist files or an Oven's proof artifacts. Checklist burns and Differential Testing worker iterations publish them automatically; future adapters use the same package API or `burnlist oven event`. The dashboard exposes one replayable `/api/events` feed across Ovens and repos. Read `references/oven-event-coordination.md` before using events to supervise worker tasks or wake a coordinator. diff --git a/skills/burnlist/references/burnlist-dashboard.md b/skills/burnlist/references/burnlist-dashboard.md index 22b69de..55c40c3 100644 --- a/skills/burnlist/references/burnlist-dashboard.md +++ b/skills/burnlist/references/burnlist-dashboard.md @@ -41,6 +41,43 @@ The script binds only to loopback hosts by default, rejects oversized plan files Agents should not start, stop, or announce this server during ordinary Burnlist execution. Their responsibility is to keep each Burnlist folder in the right lifecycle directory. The dashboard is not a global Burnlist registry; it is a read-only index over discoverable repo folders. +## Dashboard URLs + +`repoKey` is a 12-hex-character key. The dashboard uses these path-based URLs: + +- `/` — dashboard index. +- `/r//` — a Burnlist detail. +- `/r///o/` — that Burnlist through an Oven lens. +- `/r//o/` — a repo-scoped Oven. +- `/ovens` — the Oven catalog. +- `/ovens/` — an Oven explainer page. + +Older `/ovens//view?repoKey=` URLs redirect to +`/r//o/`. + +A Burnlist detail shows a lens switcher containing only Ovens whose data +contract fits its `checklist-progress@1` contract. Each link opens +`/r///o/`; `checklist` is the default lens and +the active lens is highlighted. Ovens with other contracts are not offered. + +The `/ovens` catalog lists built-in Ovens first, then custom Ovens, sorted by +name. Each card shows the Oven name, `id@version`, its `Contract: ` +badge, Built-in or Custom badge (with a custom Oven's `repoKey`), description, +an **Open Oven explainer** link, and this copyable **Tell your agent** block: + +```text +Use the Oven (@). +Its data must satisfy the contract. +Adopt the Oven before preparing its data: +burnlist oven adopt +Produce the required data, then bind it to the target path: +burnlist oven bind +``` + +An `/ovens/` explainer shows the Oven documentation and a demonstration +render with sample data. For the live, data-bound view, open the scoped +`/r//o/` or `/r///o/` URL instead. + Paginate the main Burnlist table after lifecycle filtering, with `20` rows per page. Store pages in `?page=`, reset to page one when the lifecycle filter changes, clamp invalid or oversized pages, and preserve the current filter and page through detail and back links. Place `New Oven` at the top right of the main table. Keep `Run Burn` off the primary dashboard controls; its direct `/runs/new` route remains available. The normative definition and ownership boundary are in `oven-contract.md`; this UI must preserve them. Checklist and Differential Testing are the only immutable default Ovens. New Oven creation is now `{id, name, instructions}` and the server scaffolds a starter `.oven`; the drag-to-place grid detail-builder was removed. Persist custom Ovens under ignored `.local/burnlist/ovens/` state and immutable Run snapshots under `.local/burnlist/runs/`. diff --git a/skills/burnlist/references/creating-ovens.md b/skills/burnlist/references/creating-ovens.md index 2d8a6a1..ecdd5ad 100644 --- a/skills/burnlist/references/creating-ovens.md +++ b/skills/burnlist/references/creating-ovens.md @@ -61,8 +61,9 @@ Every source has exactly one root: ``` `id`, `version`, `contract`, and `theme` are required. `refresh-seconds` is -optional. Version is currently `1`; `refresh-seconds` is a positive integer no -greater than 3600. The complete closed registries are: +optional. Version is a semver identity; built-in Ovens currently use `0.1.0`. +`refresh-seconds` is a positive integer no greater than 3600. The complete +closed registries are: | Registry | Exact allowed values | | --- | --- | @@ -233,6 +234,11 @@ with a level-one heading and a `.oven` file. Create the package with JSON payload with `burnlist oven bind `. The CLI and dashboard details are in `references/oven-authoring.md`. +The `` value is the Oven's `id@version` identity, not its +content revision. To pin a shipped Oven per project, run `burnlist oven adopt +`; it is committed under `.burnlist/ovens//`, so a Burnlist CLI upgrade +never changes it. + Here is a complete generic KPI-and-table source, `kpi.oven`: ```xml diff --git a/skills/burnlist/references/designing-ovens.md b/skills/burnlist/references/designing-ovens.md index 9395f0b..b2cf6d8 100644 --- a/skills/burnlist/references/designing-ovens.md +++ b/skills/burnlist/references/designing-ovens.md @@ -38,6 +38,11 @@ Map each signal onto the built-in view vocabulary: The real values come from a data adapter: a small, project-owned step that computes the honest numbers and writes them as one read-only JSON document. The Oven binds to those values by JSON pointer. Keep the Oven declarative: it says how to present the numbers and never computes them. +Every Oven carries an `id@version` identity, distinct from its content revision. +To pin a shipped Oven for one project, use `burnlist oven adopt `; the +committed `.burnlist/ovens//` copy remains unchanged by a Burnlist CLI +upgrade. + ## The adapter: compute honest numbers, don't type them The Oven is only the view. Between reality and the view sits the **adapter**: a small, project-owned script that reads a source of truth, computes the numbers, and writes the single read-only JSON document the Oven binds to. Skip it—hand-edit the JSON—and you have defeated the whole point: a number you typed proves nothing. diff --git a/skills/burnlist/references/oven-authoring.md b/skills/burnlist/references/oven-authoring.md index c699d5d..a7f8092 100644 --- a/skills/burnlist/references/oven-authoring.md +++ b/skills/burnlist/references/oven-authoring.md @@ -35,8 +35,9 @@ generic Oven should use `kpi-strip`/`kpi-item`, `section-header`, `log-table`/`column`, and the plain formats described in `references/creating-ovens.md`. -An Oven never executes anything. Authoring writes only custom Ovens under -ignored `.local/burnlist/ovens/` state, and changes affect only future Runs. +An Oven never executes anything. Custom-Oven authoring writes only under ignored +`.local/burnlist/ovens/` state, and changes affect only future Runs. Vendoring a +shipped Oven uses the committed per-project path described below. ## Start with `burnlist init` @@ -68,6 +69,8 @@ burnlist oven view [--json] burnlist oven bind [--repo ] burnlist oven unbind [--repo ] burnlist oven bindings [--repo ] +burnlist oven adopt [--repo ] [--force] +burnlist oven upgrade [--repo ] burnlist oven create --instructions [--oven ] [--name ] burnlist oven create --dir burnlist oven create --package @@ -75,11 +78,26 @@ burnlist oven update [same inputs as create] burnlist oven fork ``` -- `list` lists custom and built-in Ovens; `--json` emits JSON. -- `view` prints compiled structure only; it never prints bound data values. +- `list` lists custom and built-in Ovens with `id`, `version`, `name`, `kind`, + `contract`, `nodes`, and `revision` columns. Its Oven identity is + `id@version`; `version` is distinct from the `o1-sha256:` content + revision. `--json` includes `version` and `ovenRevision`. +- `view` prints compiled structure only; it never prints bound data values. Its + header identifies a shipped Oven as, for example, + `Checklist (checklist@0.1.0 · built-in)`, then reports `version`, `nodes`, + `contract`, `theme`, `revision`, and `path`. `--json` includes `version` and + `ovenRevision`. - `bind` records an Oven-to-data-file binding. - `unbind` removes an Oven-to-data-file binding. - `bindings` lists all recorded bindings. +- `adopt` copies a shipped Oven into the committed + `.burnlist/ovens//` directory and records its pin in `pin.json`. It + prints `Adopted Oven @ at /.burnlist/ovens/`. + Existing vendored Ovens require `--force`, otherwise it reports + `burnlist oven: Oven is already vendored at .` +- `upgrade` is the opt-in re-copy of a newer shipped Oven into its vendored + directory. It prints `Upgraded Oven @ at + /.burnlist/ovens/` followed by `revision: o1-sha256:`. - `create` adds a custom Oven; `update` changes an existing custom Oven only. - `fork` copies a built-in or custom Oven into a new custom id and records its `forkedFrom` provenance. Built-in Ovens are read-only and cannot be updated. @@ -91,6 +109,32 @@ contain one. Creation scaffolds a starter `.oven` when omitted, rejects an existing id unless `--force` is given, and validates before writing. `--repo` selects the repository whose binding storage is used. +## Vendoring and pinning an Oven + +`burnlist oven adopt ` copies the shipped source into the committed +`.burnlist/ovens//` directory. The vendored directory contains exactly +`.oven`, `instructions.md`, and `pin.json`; it is not the ignored +`.local/burnlist/ovens/` custom-Oven state. A pin records the declared Oven +identity and source revision: + +```json +{ + "id": "checklist", + "version": "0.1.0", + "revision": "o1-sha256:", + "source": "built-in", + "pinnedAt": "2026-07-21T20:23:35.554Z" +} +``` + +The declared `id@version` identity is distinct from the content revision: the +revision changes when source bytes change. Because the vendored copy and pin +are committed, upgrading the Burnlist CLI never silently changes a project's +Oven. Run `burnlist oven upgrade ` to opt in to copying the shipped source +again, then commit the changed vendored directory. The dashboard resolves a +repo's vendored Oven before the shipped built-in when +`.burnlist/ovens//` exists; otherwise it uses the shipped built-in. + ## Binding & viewing `burnlist oven bind ` stores the path exactly as supplied. The record From de8e9d6dee5c219c19733de12e868c4de21335a3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 22:37:12 +0200 Subject: [PATCH 08/21] docs: mirror oven versioning, adopt/upgrade pinning, and dashboard URL scheme on the website --- website/scripts/skill-content.mjs | 6 +- website/src/content/docs/cli.mdx | 4 +- website/src/content/docs/dashboard.mdx | 19 ++++++ website/src/content/docs/ovens/authoring.mdx | 66 +++++++++++++++++++ .../content/docs/ovens/designing-ovens.mdx | 2 + .../src/content/docs/ovens/oven-system.mdx | 2 + 6 files changed, 95 insertions(+), 4 deletions(-) diff --git a/website/scripts/skill-content.mjs b/website/scripts/skill-content.mjs index 3cf9b58..4de25c1 100644 --- a/website/scripts/skill-content.mjs +++ b/website/scripts/skill-content.mjs @@ -24,7 +24,7 @@ Usage: burnlist differential-testing sdk burnlist streaming-diff ... burnlist hooks [install|uninstall|status] [--agent codex,claude] [--untracked] (bare defaults to status) - burnlist oven ... + burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] burnlist ready [--repo ] @@ -196,7 +196,9 @@ An Oven is a named, declarative, non-executable recipe for a Burn — data, neve - **Performance Tracing** renders retained browser-output timing evidence — frame pacing, budget checks, and slow steps — from a project-owned trace run. - **Visual Parity** compares trusted reference and candidate frames as isolated render passes, gating each render domain on calibrated channel, mean-delta, and changed-pixel bounds. -Author and inspect ovens with \`burnlist oven \`. Custom ovens live in ignored, repo-scoped \`.local/burnlist/ovens/\` state. +Author and inspect ovens with \`burnlist oven \`. Custom ovens live in ignored, repo-scoped \`.local/burnlist/ovens/\` state. + +Ovens carry an \`id@version\` identity, distinct from the content revision. Vendoring a shipped Oven with \`burnlist oven adopt \` copies and pins it in committed \`.burnlist/ovens//\`; \`burnlist oven upgrade \` is the opt-in way to move that pin. Dashboard paths are \`/r//\` for a Burnlist, \`/r///o/\` for its Oven lens, \`/r//o/\` for a repo-scoped Oven, \`/ovens\` for the catalog, and \`/ovens/\` for the explainer. ## Documentation diff --git a/website/src/content/docs/cli.mdx b/website/src/content/docs/cli.mdx index 593c523..831c303 100644 --- a/website/src/content/docs/cli.mdx +++ b/website/src/content/docs/cli.mdx @@ -51,10 +51,10 @@ burnlist init [path] [--track] ## Ovens ```sh -burnlist oven ... +burnlist oven ... ``` -Author and inspect Ovens. (`burnlist oven help` lists a few more subcommands, such as `fork`.) See [Ovens](/ovens). +Author and inspect Ovens. Ovens carry an `id@version` identity and can be vendored and pinned per project with `adopt` (committed under `.burnlist/ovens//`) and opt-in `upgrade`. (`burnlist oven help` lists a few more subcommands, such as `fork`.) See [Ovens](/ovens). ## Skills diff --git a/website/src/content/docs/dashboard.mdx b/website/src/content/docs/dashboard.mdx index b6fdf42..1a4dcd3 100644 --- a/website/src/content/docs/dashboard.mdx +++ b/website/src/content/docs/dashboard.mdx @@ -45,6 +45,25 @@ A Burnlist in an unregistered repository is still visible when the dashboard lau New Oven and Run Burn are explicit, user-controlled local controller surfaces. They write local controller records under `.local/burnlist/` by default: custom Ovens under `.local/burnlist/ovens/` and immutable Run snapshots under `.local/burnlist/runs/`. They do not change canonical task state, execute instructions, or start an agent. See [Ovens](/ovens). +## Dashboard URLs + +Dashboard URLs keep the repository key in the path. A `repoKey` is 12 hexadecimal characters. The index is `/`; a Burnlist detail is `/r//`; and a Burnlist through an Oven lens is `/r///o/`. A repo-scoped Oven is `/r//o/`. + +A Burnlist detail has a lens-switcher that includes only Ovens whose data contract fits its `checklist-progress@1` contract. Its links open the Burnlist-scoped Oven URL, the active lens is highlighted, and `checklist` is the default lens. For example, the checklist lens is `/r///o/checklist`. + +The Oven catalog is `/ovens`. It lists built-in Ovens first, then custom Ovens, sorted by name. Each card shows the Oven name, its `id@version` label, a `Contract: ` badge, a Built-in or Custom badge (and the `repoKey` for custom Ovens), its description, an **Open Oven explainer** link, and a copyable **Tell your agent** block: + +```text +Use the Oven (@). +Its data must satisfy the contract. +Adopt the Oven before preparing its data: +burnlist oven adopt +Produce the required data, then bind it to the target path: +burnlist oven bind +``` + +An Oven explainer lives at `/ovens/`. It shows the Oven documentation and a non-live demonstration rendered with sample data. Open the scoped Oven URL to see the live data-bound view. Legacy `/ovens//view?repoKey=` URLs redirect to `/r//o/`. + ## Local state Dashboard observer state, custom Ovens, and Run snapshots live under `.local/burnlist/`. Keep `notes/burnlists/` and `.local/burnlist/` ignored unless you deliberately want to share task state. diff --git a/website/src/content/docs/ovens/authoring.mdx b/website/src/content/docs/ovens/authoring.mdx index 83c7c75..9578d1c 100644 --- a/website/src/content/docs/ovens/authoring.mdx +++ b/website/src/content/docs/ovens/authoring.mdx @@ -151,6 +151,25 @@ Successful binding prints `Bound Oven to ` and `No binding exists…`. `fork` copies a built-in or custom Oven to a new custom id and records its `forkedFrom` provenance; built-in Ovens cannot be updated. +Shipped and custom Ovens can be listed, and an Oven can be inspected by its id: + +```sh +burnlist oven list [--json] +burnlist oven view [--json] +``` + +`list` reports `id`, `version`, `name`, `kind`, `contract`, `nodes`, and +`revision`; `view` identifies an Oven as `id@version`. The semver version is +distinct from the `o1-sha256:` content revision. With `--json`, both +commands include `version` and `ovenRevision`: + +```text +Checklist (checklist@0.1.0 · built-in) +version: 0.1.0 · nodes: 11 · contract: checklist-progress@1 · theme: checklist +revision: o1-sha256: +path: ... +``` + Use the CLI to inspect structure only: ```sh @@ -160,6 +179,53 @@ burnlist oven view [--json] It prints the compiled node tree and a `node / prop / source` pointer table. It never shows data values, even when an Oven is bound. +### Vendoring and pinning + +Adopt a shipped Oven to copy its source into the committed per-project +directory, or opt in to moving that pin later: + +```sh +burnlist oven adopt [--repo ] [--force] +burnlist oven upgrade [--repo ] +``` + +The vendored directory contains exactly the Oven source, its instructions, and +its pin record: + +```text +.burnlist/ovens// + .oven + instructions.md + pin.json +``` + +```json +{ + "id": "", + "version": "0.1.0", + "revision": "o1-sha256:", + "source": "built-in", + "pinnedAt": "" +} +``` + +Adoption and upgrade print: + +```text +Adopted Oven @ at /.burnlist/ovens/ +burnlist oven: Oven is already vendored at . +Upgraded Oven @ at /.burnlist/ovens/ +revision: o1-sha256: +``` + +Re-adopting without `--force` reports the second line; `--force` copies the +Oven again. An upgrade is opt-in: it copies the newer shipped source and moves +the pin. + +Because the vendored copy and pin are committed, upgrading the Burnlist CLI +never changes a pinned Oven. Only `upgrade` moves it, and the dashboard resolves +a repo's vendored Oven ahead of the shipped built-in. + To render with data, run the dashboard: ```sh diff --git a/website/src/content/docs/ovens/designing-ovens.mdx b/website/src/content/docs/ovens/designing-ovens.mdx index 702998f..3851cbb 100644 --- a/website/src/content/docs/ovens/designing-ovens.mdx +++ b/website/src/content/docs/ovens/designing-ovens.mdx @@ -41,6 +41,8 @@ Map each signal onto the built-in view vocabulary: The real values come from a data adapter: a small, project-owned step that computes the honest numbers and writes them as one read-only JSON document. The Oven binds to those values by JSON pointer. Keep the Oven declarative: it says how to present the numbers and never computes them. +Ovens carry an `id@version` identity, and a shipped Oven can be pinned per project with `burnlist oven adopt `. + For the [.oven DSL reference](/ovens/dsl-reference), see the full grammar and examples. For creating and binding an Oven from the CLI—`burnlist oven create`, `burnlist oven bind`, and `burnlist oven view`—see [authoring](/ovens/authoring). ## The adapter: compute honest numbers, don't type them diff --git a/website/src/content/docs/ovens/oven-system.mdx b/website/src/content/docs/ovens/oven-system.mdx index 2936961..1a71ba8 100644 --- a/website/src/content/docs/ovens/oven-system.mdx +++ b/website/src/content/docs/ovens/oven-system.mdx @@ -17,6 +17,8 @@ A `.oven` file is declarative, non-executable detail-page data. Its XML-like `` under committed `.burnlist/ovens//`; use opt-in `burnlist oven upgrade ` to move that pin. + ## Rendering pipeline ```text From 18f7c050742f71eb6759a86843b7c4741e4af3c9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 21 Jul 2026 23:13:31 +0200 Subject: [PATCH 09/21] fix: list oven adopt, upgrade, and fork verbs in the top-level help --- bin/burnlist.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 976df75..9353343 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -98,7 +98,7 @@ Usage: burnlist differential-testing sdk burnlist streaming-diff ... burnlist hooks [install|uninstall|status] [--agent codex,claude] [--untracked] (bare defaults to status) - burnlist oven ... + burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] burnlist ready [--repo ] From e9ddf3e343a635f0e5a36258c0c0e629c1177628 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 22 Jul 2026 01:33:02 +0200 Subject: [PATCH 10/21] feat: share built-in oven runtime validation authority (260721-002) --- ovens/differential-testing/engine/handler.mjs | 12 +++-- ovens/model-lab/engine/model-lab-handler.mjs | 8 ++- ovens/performance-tracing/handler.mjs | 12 +++-- .../engine/streaming-diff-handler.mjs | 3 +- ovens/visual-parity/handler.mjs | 8 ++- scripts/verify.mjs | 9 ++-- src/ovens/handlers/generic-json-handler.mjs | 11 ++++- src/ovens/oven-registry.mjs | 26 ++++++++++ src/ovens/oven-registry.test.mjs | 49 ++++++++++++++++++- 9 files changed, 121 insertions(+), 17 deletions(-) diff --git a/ovens/differential-testing/engine/handler.mjs b/ovens/differential-testing/engine/handler.mjs index a0b28fc..cb0b382 100644 --- a/ovens/differential-testing/engine/handler.mjs +++ b/ovens/differential-testing/engine/handler.mjs @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import { basename, dirname, relative, resolve } from "node:path"; -import { registerOvenHandler } from "../../../src/ovens/oven-registry.mjs"; +import { OVEN_DATA_INPUT, registerOvenHandler } from "../../../src/ovens/oven-registry.mjs"; import { readTextFileWithLimit, safeStat } from "../../../src/server/fs-safe.mjs"; import { assertDifferentialTestingData } from "./data-contract.mjs"; import { @@ -12,6 +12,8 @@ import { readDifferentialTestingBundleScenario, } from "./transport.mjs"; +export const validateDifferentialTestingRuntimeData = assertDifferentialTestingData; + function differentialTestingIndexCache(ctx, path) { const readPath = resolve(realpathSync(dirname(path)), basename(path)); const cacheKey = `index:${readPath}`; @@ -50,7 +52,7 @@ function differentialTestingIndexCache(ctx, path) { } : null, }; } else { - assertDifferentialTestingData(document); + validateDifferentialTestingRuntimeData(document); index = { kind: "legacy", signature, @@ -131,7 +133,7 @@ function differentialTestingScenarioCache(ctx, index, scenarioId) { const signature = `${index.signature}\0${scenarioPath}\0${stat.ino}\0${stat.size}\0${stat.mtimeMs}`; if (cached?.signature === signature) return cached; const sourcePayload = JSON.parse(readTextFileWithLimit(scenarioPath, ctx.maxOvenDataBytes, `Differential Testing scenario ${scenarioId}`)); - assertDifferentialTestingData(sourcePayload); + validateDifferentialTestingRuntimeData(sourcePayload); if (sourcePayload.scenarioCatalog.selectedScenarioId !== scenarioId) { throw Object.assign(new Error(`scenario file ${scenarioId} selects ${sourcePayload.scenarioCatalog.selectedScenarioId}`), { status: 422 }); } @@ -141,7 +143,7 @@ function differentialTestingScenarioCache(ctx, index, scenarioId) { throw Object.assign(new Error(`scenario file ${scenarioId} does not match its published catalog entry`), { status: 422 }); } const payload = { ...sourcePayload, scenarioCatalog: { selectedScenarioId: scenarioId, scenarios: index.scenarios } }; - assertDifferentialTestingData(payload); + validateDifferentialTestingRuntimeData(payload); const source = JSON.stringify(payload); const result = { signature, @@ -176,6 +178,8 @@ function sendDifferentialTestingData(req, res, cached) { export const differentialTestingHandler = Object.freeze({ id: "differential-testing", + dataInput: OVEN_DATA_INPUT.jsonPayload, + validateData: validateDifferentialTestingRuntimeData, warmIntervalMs: 1_000, dashboardEntries(ctx) { diff --git a/ovens/model-lab/engine/model-lab-handler.mjs b/ovens/model-lab/engine/model-lab-handler.mjs index 3a28c85..96ea1ea 100644 --- a/ovens/model-lab/engine/model-lab-handler.mjs +++ b/ovens/model-lab/engine/model-lab-handler.mjs @@ -1,18 +1,22 @@ import { basename } from "node:path"; -import { registerOvenHandler } from "../../../src/ovens/oven-registry.mjs"; +import { OVEN_DATA_INPUT, registerOvenHandler } from "../../../src/ovens/oven-registry.mjs"; import { readTextFileWithLimit, safeStat } from "../../../src/server/fs-safe.mjs"; import { assertModelLabData } from "./model-lab-contract.mjs"; +export const validateModelLabRuntimeData = assertModelLabData; + function readModelLabData(bindingPath, maxOvenDataBytes) { const stat = safeStat(bindingPath); if (!stat?.isFile()) throw Object.assign(new Error("configured Model Lab data is missing"), { status: 404 }); const payload = JSON.parse(readTextFileWithLimit(bindingPath, maxOvenDataBytes, "Model Lab Oven data")); - assertModelLabData(payload); + validateModelLabRuntimeData(payload); return { payload, stat }; } export const modelLabHandler = Object.freeze({ id: "model-lab", + dataInput: OVEN_DATA_INPUT.jsonPayload, + validateData: validateModelLabRuntimeData, serveData({ bindingPath, maxOvenDataBytes }) { const { payload } = readModelLabData(bindingPath, maxOvenDataBytes); diff --git a/ovens/performance-tracing/handler.mjs b/ovens/performance-tracing/handler.mjs index ee8bbfb..8485ce6 100644 --- a/ovens/performance-tracing/handler.mjs +++ b/ovens/performance-tracing/handler.mjs @@ -1,12 +1,14 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname, isAbsolute, relative, resolve } from "node:path"; -import { registerOvenHandler } from "../../src/ovens/oven-registry.mjs"; +import { OVEN_DATA_INPUT, registerOvenHandler } from "../../src/ovens/oven-registry.mjs"; import { readTextFileWithLimit, safeStat } from "../../src/server/fs-safe.mjs"; import { assertPerformanceTracingData } from "./contract.mjs"; export const performanceTracingHandler = Object.freeze({ id: "performance-tracing", + dataInput: OVEN_DATA_INPUT.jsonPayload, + validateData: validatePerformanceTracingRuntimeData, serveData({ bindingPath, maxOvenDataBytes }) { if (!safeStat(bindingPath)?.isFile()) { @@ -17,12 +19,16 @@ export const performanceTracingHandler = Object.freeze({ maxOvenDataBytes, "Performance Tracing Oven data", )); - assertPerformanceTracingProvenanceCurrent(payload, bindingPath, maxOvenDataBytes); - assertPerformanceTracingData(payload); + validatePerformanceTracingRuntimeData(payload, { bindingPath, maxOvenDataBytes }); return { ovenId: "performance-tracing", path: bindingPath, payload, validated: true }; }, }); +export function validatePerformanceTracingRuntimeData(payload, { bindingPath, maxOvenDataBytes } = {}) { + assertPerformanceTracingProvenanceCurrent(payload, bindingPath, maxOvenDataBytes); + return assertPerformanceTracingData(payload); +} + export function assertPerformanceTracingProvenanceCurrent(payload, bindingPath, maxBytes = 512 * 1024 * 1024) { const files = payload?.provenance?.files; if (!files || typeof files !== "object" || !Object.keys(files).length) { diff --git a/ovens/streaming-diff/engine/streaming-diff-handler.mjs b/ovens/streaming-diff/engine/streaming-diff-handler.mjs index d93fb63..99aa603 100644 --- a/ovens/streaming-diff/engine/streaming-diff-handler.mjs +++ b/ovens/streaming-diff/engine/streaming-diff-handler.mjs @@ -1,7 +1,7 @@ import { opendirSync, realpathSync } from "node:fs"; import { join, relative, sep } from "node:path"; -import { registerOvenHandler } from "../../../src/ovens/oven-registry.mjs"; +import { OVEN_DATA_INPUT, registerOvenHandler } from "../../../src/ovens/oven-registry.mjs"; import { containedJoin } from "../../../src/server/repo-state.mjs"; import { STREAMING_DIFF_FEED_VERSION, STREAMING_DIFF_OVEN_ID, identifierPathComponent, streamingDiffIdentifier } from "./streaming-diff-feed.mjs"; import { readJournal, readManifestPure, resolveReconnect } from "./streaming-diff-journal.mjs"; @@ -302,6 +302,7 @@ function startSse(ctx, feed) { export const streamingDiffHandler = Object.freeze({ id: STREAMING_DIFF_OVEN_ID, + dataInput: OVEN_DATA_INPUT.producerManaged, serveData(ctx) { try { diff --git a/ovens/visual-parity/handler.mjs b/ovens/visual-parity/handler.mjs index 4c73452..751bc49 100644 --- a/ovens/visual-parity/handler.mjs +++ b/ovens/visual-parity/handler.mjs @@ -1,13 +1,15 @@ import { basename } from "node:path"; -import { registerOvenHandler } from "../../src/ovens/oven-registry.mjs"; +import { OVEN_DATA_INPUT, registerOvenHandler } from "../../src/ovens/oven-registry.mjs"; import { readTextFileWithLimit, safeStat } from "../../src/server/fs-safe.mjs"; import { assertVisualParityData } from "./contract.mjs"; +export const validateVisualParityRuntimeData = assertVisualParityData; + function readVisualParityData(bindingPath, maxOvenDataBytes) { const stat = safeStat(bindingPath); if (!stat?.isFile()) throw new Error("configured Visual Parity data is missing"); const payload = JSON.parse(readTextFileWithLimit(bindingPath, maxOvenDataBytes, "Visual Parity Oven data")); - assertVisualParityData(payload); + validateVisualParityRuntimeData(payload); return { payload, stat }; } @@ -22,6 +24,8 @@ function targetProgress(payload) { export const visualParityHandler = Object.freeze({ id: "visual-parity", + dataInput: OVEN_DATA_INPUT.jsonPayload, + validateData: validateVisualParityRuntimeData, serveData({ bindingPath, maxOvenDataBytes }) { const { payload } = readVisualParityData(bindingPath, maxOvenDataBytes); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index c398245..7d56bb6 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -275,9 +275,12 @@ assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", 'assertKnownKey assertSourceIncludes(".github/workflows/publish.yml", "git fetch origin main", "Publish reruns must refresh origin/main before release-state checks."); assertSourceIncludes(".github/workflows/publish.yml", '"refs/tags/${VERSION}^{}"', "Publish tag verification must request annotated-tag peeled refs."); assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", "ovenId(record.ovenId);", "Burn run reads do not require the canonical ovenId."); -assertSourceIncludes("ovens/differential-testing/engine/handler.mjs", "assertDifferentialTestingData(payload)", "Differential Testing data is not validated at the server boundary."); -assertSourceIncludes("ovens/performance-tracing/handler.mjs", "assertPerformanceTracingData(payload)", "Performance Tracing data is not validated at the server boundary."); -assertSourceIncludes("ovens/visual-parity/handler.mjs", "assertVisualParityData(payload)", "Visual Parity data is not validated at the server boundary."); +assertSourceIncludes("ovens/differential-testing/engine/handler.mjs", "validateData: validateDifferentialTestingRuntimeData", "Differential Testing does not expose its server-boundary validator."); +assertSourceIncludes("ovens/differential-testing/engine/handler.mjs", "validateDifferentialTestingRuntimeData(payload)", "Differential Testing data is not validated at the server boundary."); +assertSourceIncludes("ovens/performance-tracing/handler.mjs", "validateData: validatePerformanceTracingRuntimeData", "Performance Tracing does not expose its server-boundary validator."); +assertSourceIncludes("ovens/performance-tracing/handler.mjs", "validatePerformanceTracingRuntimeData(payload", "Performance Tracing data is not validated at the server boundary."); +assertSourceIncludes("ovens/visual-parity/handler.mjs", "validateData: validateVisualParityRuntimeData", "Visual Parity does not expose its server-boundary validator."); +assertSourceIncludes("ovens/visual-parity/handler.mjs", "validateVisualParityRuntimeData(payload)", "Visual Parity data is not validated at the server boundary."); assertSourceIncludes("ovens/differential-testing/engine/handler.mjs", 'ovenName: "Differential Testing"', "Differential Testing scenarios are missing from the shared dashboard table."); assertSourceIncludes("ovens/differential-testing/engine/handler.mjs", "queryDifferentialTestingFieldPage", "Differential Testing server is missing bounded field-page transport."); assertSourceExcludes("src/server/burnlist-dashboard-server.mjs", 'id === "differential-testing"', "Dashboard server still hardcodes the Differential Testing Oven."); diff --git a/src/ovens/handlers/generic-json-handler.mjs b/src/ovens/handlers/generic-json-handler.mjs index 9cadf70..f462d03 100644 --- a/src/ovens/handlers/generic-json-handler.mjs +++ b/src/ovens/handlers/generic-json-handler.mjs @@ -1,13 +1,22 @@ import { readTextFileWithLimit, safeStat } from "../../server/fs-safe.mjs"; +import { OVEN_DATA_INPUT } from "../oven-registry.mjs"; + +export function validateGenericJsonData(payload) { + return payload; +} export const genericJsonHandler = Object.freeze({ id: "checklist", + dataInput: OVEN_DATA_INPUT.jsonPayload, + validateData: validateGenericJsonData, serveData({ id, bindingPath, maxOvenDataBytes }) { if (!safeStat(bindingPath)?.isFile()) { throw Object.assign(new Error(`configured data for Oven ${id} is missing`), { status: 404 }); } - const payload = JSON.parse(readTextFileWithLimit(bindingPath, maxOvenDataBytes, `Oven ${id} data`)); + const payload = validateGenericJsonData( + JSON.parse(readTextFileWithLimit(bindingPath, maxOvenDataBytes, `Oven ${id} data`)), + ); return { ovenId: id, path: bindingPath, payload, validated: false }; }, diff --git a/src/ovens/oven-registry.mjs b/src/ovens/oven-registry.mjs index 3b568fe..73023cc 100644 --- a/src/ovens/oven-registry.mjs +++ b/src/ovens/oven-registry.mjs @@ -6,6 +6,31 @@ import { ovenId } from "./oven-contract.mjs"; // serialize. Context contains only the request-specific values each hook needs. const handlers = new Map(); +export const OVEN_DATA_INPUT = Object.freeze({ + jsonPayload: "json-payload", + producerManaged: "producer-managed", +}); + +const dataInputs = new Set(Object.values(OVEN_DATA_INPUT)); + +function validateDataCapability(handler, id) { + if (handler.dataInput === undefined) { + if (handler.validateData !== undefined) { + throw new Error(`Oven handler ${id} validateData requires dataInput.`); + } + return; + } + if (!dataInputs.has(handler.dataInput)) { + throw new Error(`Oven handler ${id} dataInput must be json-payload or producer-managed.`); + } + if (handler.dataInput === OVEN_DATA_INPUT.jsonPayload && typeof handler.validateData !== "function") { + throw new Error(`Oven handler ${id} json-payload dataInput requires validateData.`); + } + if (handler.dataInput === OVEN_DATA_INPUT.producerManaged && handler.validateData !== undefined) { + throw new Error(`Oven handler ${id} producer-managed dataInput cannot expose validateData.`); + } +} + export function registerOvenHandler(id, handler) { const normalizedId = ovenId(id); if (!handler || typeof handler !== "object") throw new Error(`Oven handler for ${normalizedId} must be an object.`); @@ -16,6 +41,7 @@ export function registerOvenHandler(id, handler) { throw new Error(`Oven handler ${normalizedId} ${hook} must be a function.`); } } + validateDataCapability(handler, normalizedId); if (handler.warmIntervalMs !== undefined && (!Number.isInteger(handler.warmIntervalMs) || handler.warmIntervalMs <= 0)) { throw new Error(`Oven handler ${normalizedId} warmIntervalMs must be a positive integer.`); diff --git a/src/ovens/oven-registry.test.mjs b/src/ovens/oven-registry.test.mjs index fa35ac6..cbe3ade 100644 --- a/src/ovens/oven-registry.test.mjs +++ b/src/ovens/oven-registry.test.mjs @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getOvenHandler, listOvenHandlers, registerOvenHandler } from "./oven-registry.mjs"; +import { + OVEN_DATA_INPUT, + getOvenHandler, + listOvenHandlers, + registerOvenHandler, +} from "./oven-registry.mjs"; test("the Oven handler registry validates and retrieves code-owned handlers", () => { const handler = { id: "registry-test", dashboardEntries() { return []; } }; @@ -20,3 +25,45 @@ test("the Oven handler registry validates and retrieves code-owned handlers", () assert.throws(() => registerOvenHandler("registry-hook", { id: "registry-hook", warm: true }), /must be a function/u); assert.throws(() => registerOvenHandler("registry-warm", { id: "registry-warm", warmIntervalMs: 0 }), /positive integer/u); }); + +test("the registry validates pre-write data capabilities", () => { + const validateData = (payload) => payload; + const payloadHandler = registerOvenHandler("registry-payload", { + id: "registry-payload", + dataInput: OVEN_DATA_INPUT.jsonPayload, + validateData, + }); + const producerHandler = registerOvenHandler("registry-producer", { + id: "registry-producer", + dataInput: OVEN_DATA_INPUT.producerManaged, + }); + + assert.equal(payloadHandler.validateData, validateData); + assert.equal(producerHandler.validateData, undefined); + assert.throws(() => registerOvenHandler("registry-input", { + id: "registry-input", + dataInput: "unknown", + }), /dataInput/u); + assert.throws(() => registerOvenHandler("registry-validator", { + id: "registry-validator", + dataInput: OVEN_DATA_INPUT.jsonPayload, + }), /validateData/u); + assert.throws(() => registerOvenHandler("registry-managed", { + id: "registry-managed", + dataInput: OVEN_DATA_INPUT.producerManaged, + validateData, + }), /producer-managed/u); +}); + +test("every built-in declares its real runtime data capability", async () => { + await import("./built-in-handlers.mjs"); + + for (const id of ["checklist", "differential-testing", "model-lab", "performance-tracing", "visual-parity"]) { + const handler = getOvenHandler(id); + assert.equal(handler.dataInput, OVEN_DATA_INPUT.jsonPayload, id); + assert.equal(typeof handler.validateData, "function", id); + } + const streamingDiff = getOvenHandler("streaming-diff"); + assert.equal(streamingDiff.dataInput, OVEN_DATA_INPUT.producerManaged); + assert.equal(streamingDiff.validateData, undefined); +}); From 7d9835b92f7a75e20d51fb5aef127d1f6b5404df Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 22 Jul 2026 01:33:29 +0200 Subject: [PATCH 11/21] feat: dispatch oven data through runtime validation (260721-002) --- scripts/verify-test-files.mjs | 1 + src/ovens/oven-data-validate.mjs | 135 ++++++++++++++++++++++++++ src/ovens/oven-data-validate.test.mjs | 108 +++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/ovens/oven-data-validate.mjs create mode 100644 src/ovens/oven-data-validate.test.mjs diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 3a48709..e12e846 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -14,6 +14,7 @@ export const verificationTestFiles = [ "src/server/burnlist-discovery.test.mjs", "src/ovens/oven-contract.test.mjs", "src/ovens/oven-registry.test.mjs", + "src/ovens/oven-data-validate.test.mjs", "src/ovens/dsl/xml-scan.test.mjs", "src/ovens/dsl/oven-validate.test.mjs", "src/ovens/dsl/oven-compile.test.mjs", diff --git a/src/ovens/oven-data-validate.mjs b/src/ovens/oven-data-validate.mjs new file mode 100644 index 0000000..f7f1239 --- /dev/null +++ b/src/ovens/oven-data-validate.mjs @@ -0,0 +1,135 @@ +import "./built-in-handlers.mjs"; +import { compileOven } from "./dsl/oven-compile.mjs"; +import { getOvenHandler, OVEN_DATA_INPUT } from "./oven-registry.mjs"; + +export const SHAPE_ONLY_WARNING = "shape-only validation checks source pointers, not payload truth."; + +const missing = Symbol("missing Oven source"); + +function result(ok, authority, payload, errors, warnings = []) { + return { + ok, + authority, + ...(ok ? { payload } : {}), + errors, + warnings, + }; +} + +function runtimeErrors(error) { + if (Array.isArray(error?.issues) && error.issues.length > 0) { + return error.issues.map((issue) => ({ + path: typeof issue?.path === "string" ? issue.path : "$", + message: String(issue?.message ?? "Runtime validation failed."), + })); + } + return [{ + path: "$", + message: String(error?.message ?? error ?? "Runtime validation failed."), + }]; +} + +function resolvePointer(payload, pointer) { + if (pointer === "" || pointer === "/" || pointer === "@item") return payload; + const source = pointer.startsWith("@item/") ? pointer.slice(5) : pointer; + if (!source.startsWith("/")) return missing; + const segments = source.slice(1).split("/") + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); + let value = payload; + for (const segment of segments) { + if (value === null || value === undefined + || (typeof value !== "object" && typeof value !== "function") + || !Object.hasOwn(value, segment)) return missing; + value = value[segment]; + } + return value; +} + +function sourceValues(pointer, payload, itemContexts) { + if (pointer.startsWith("@item")) { + if (!itemContexts) return []; + return itemContexts.map(({ value, index }) => ({ + value: resolvePointer(value, pointer), + index, + })); + } + return [{ value: resolvePointer(payload, pointer), index: null }]; +} + +function sourceErrors(ir, payload) { + const errors = []; + + function check(pointer, itemContexts) { + if (typeof pointer !== "string") return; + for (const resolved of sourceValues(pointer, payload, itemContexts)) { + if (resolved.value !== missing) continue; + errors.push({ + path: pointer, + message: resolved.index === null + ? "Oven source pointer does not resolve in the payload." + : `Oven source pointer does not resolve for collection item ${resolved.index}.`, + }); + } + } + + function itemValues(pointer, itemContexts) { + if (typeof pointer !== "string") return []; + const contexts = []; + for (const resolved of sourceValues(pointer, payload, itemContexts)) { + if (!Array.isArray(resolved.value)) continue; + resolved.value.forEach((value, index) => contexts.push({ + value, + index: resolved.index === null ? String(index) : `${resolved.index}.${index}`, + })); + } + return contexts; + } + + function visit(node, itemContexts = null) { + const pointer = node?.attributes?.source; + check(pointer, itemContexts); + const nestedItems = node.kind === "collection" || node.kind === "log-table" + ? itemValues(pointer, itemContexts) + : null; + for (const child of node.children ?? []) { + const entersItemScope = (node.kind === "collection" && child.kind === "each") + || (node.kind === "log-table" && child.kind === "column"); + visit(child, entersItemScope ? nestedItems : itemContexts); + } + } + + for (const node of ir.root) visit(node); + return errors; +} + +function compileErrors(compiled) { + return compiled.diagnostics.map((diagnostic) => ({ + path: diagnostic.path || "$", + message: diagnostic.message, + code: diagnostic.code, + })); +} + +export function validateOvenData(oven, payload, context = {}) { + const handler = getOvenHandler(oven?.id); + if (handler?.dataInput === OVEN_DATA_INPUT.producerManaged) { + return result(false, "producer-managed", payload, [{ + path: "$", + message: `Oven ${handler.id} is producer-managed and cannot accept a single JSON payload.`, + }]); + } + if (handler?.dataInput === OVEN_DATA_INPUT.jsonPayload) { + try { + handler.validateData(payload, context); + return result(true, "runtime", payload, []); + } catch (error) { + return result(false, "runtime", payload, runtimeErrors(error)); + } + } + + const warnings = [SHAPE_ONLY_WARNING]; + const compiled = compileOven(oven?.oven ?? "", { file: `${oven?.id ?? "custom"}.oven` }); + if (!compiled.ok) return result(false, "shape-only", payload, compileErrors(compiled), warnings); + const errors = sourceErrors(compiled.ir, payload); + return result(errors.length === 0, "shape-only", payload, errors, warnings); +} diff --git a/src/ovens/oven-data-validate.test.mjs b/src/ovens/oven-data-validate.test.mjs new file mode 100644 index 0000000..d8c5f88 --- /dev/null +++ b/src/ovens/oven-data-validate.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { getOvenHandler } from "./oven-registry.mjs"; +import { + SHAPE_ONLY_WARNING, + validateOvenData, +} from "./oven-data-validate.mjs"; + +const customSource = (body) => `${body}`; + +test("built-in validation dispatches through the registered runtime authority", () => { + const payload = { schema: "plausible-but-not-runtime-valid" }; + let runtimeError; + try { + getOvenHandler("differential-testing").validateData(payload); + } catch (error) { + runtimeError = error; + } + + const result = validateOvenData({ id: "differential-testing" }, payload); + + assert.equal(result.ok, false); + assert.equal(result.authority, "runtime"); + assert.deepEqual(result.errors, runtimeError.issues); + assert.deepEqual(result.warnings, []); +}); + +test("built-in success and producer-managed rejection are capability-driven", () => { + const payload = { any: ["JSON", "shape"] }; + assert.deepEqual(validateOvenData({ id: "checklist" }, payload), { + ok: true, + authority: "runtime", + payload, + errors: [], + warnings: [], + }); + + const producer = validateOvenData({ id: "streaming-diff" }, payload); + assert.equal(producer.ok, false); + assert.equal(producer.authority, "producer-managed"); + assert.match(producer.errors[0].message, /producer-managed.*single JSON payload/u); +}); + +test("custom Ovens receive shape-only pointer validation", () => { + const oven = { + id: "custom-shape", + oven: customSource(` + + + + `), + }; + const payload = { summary: { "~value": 2 }, rows: [{ "/count": 1 }] }; + + assert.deepEqual(validateOvenData(oven, payload), { + ok: true, + authority: "shape-only", + payload, + errors: [], + warnings: [SHAPE_ONLY_WARNING], + }); + + const missing = validateOvenData(oven, { summary: { "~value": 2 }, rows: [{}] }); + assert.equal(missing.ok, false); + assert.deepEqual(missing.errors, [{ + path: "/rows/0/~1count", + message: "Oven source pointer does not resolve in the payload.", + }]); +}); + +test("custom item pointers resolve against each collection item", () => { + const oven = { + id: "custom-shape", + oven: customSource(` + + + `), + }; + + assert.equal(validateOvenData(oven, { rows: [{ id: "a", value: 1 }] }).ok, true); + assert.deepEqual(validateOvenData(oven, { rows: [{ id: "a" }] }).errors, [{ + path: "@item/value", + message: "Oven source pointer does not resolve for collection item 0.", + }]); + assert.equal(validateOvenData(oven, { rows: [] }).ok, true); +}); + +test("invalid custom RFC 6901 sources return structured compile errors", () => { + const result = validateOvenData({ + id: "custom-shape", + oven: customSource(''), + }, {}); + + assert.equal(result.ok, false); + assert.equal(result.authority, "shape-only"); + assert.ok(result.errors.some((error) => error.code === "SCALAR_POINTER")); + assert.deepEqual(result.warnings, [SHAPE_ONLY_WARNING]); +}); + +test("the dispatcher contains no schema validation or per-Oven id table", () => { + const source = readFileSync(fileURLToPath(new URL("./oven-data-validate.mjs", import.meta.url)), "utf8"); + assert.doesNotMatch(source, /data\.schema|json[ -]?schema/iu); + for (const id of ["differential-testing", "model-lab", "performance-tracing", "visual-parity"]) { + assert.equal(source.includes(id), false, id); + } +}); From 0f2ea3291e05522fc6388ae8a674a1b35772c57f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 22 Jul 2026 01:33:56 +0200 Subject: [PATCH 12/21] feat: transact canonical oven data and bindings (260721-002) --- scripts/verify-test-files.mjs | 1 + src/server/oven-bindings.mjs | 22 ++-- src/server/oven-data-store.mjs | 172 ++++++++++++++++++++++++ src/server/oven-data-store.test.mjs | 196 ++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 src/server/oven-data-store.mjs create mode 100644 src/server/oven-data-store.test.mjs diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index e12e846..17fe4a2 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -64,6 +64,7 @@ export const verificationTestFiles = [ "src/cli/hooks-config.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", "src/server/oven-warm.test.mjs", "src/server/oven-vendor.test.mjs", "src/server/oven-vendor-resolution.test.mjs", diff --git a/src/server/oven-bindings.mjs b/src/server/oven-bindings.mjs index bcc47ca..6716c6e 100644 --- a/src/server/oven-bindings.mjs +++ b/src/server/oven-bindings.mjs @@ -81,7 +81,7 @@ function mutableBindingStore(repoRoot) { return result.store; } -function writeStore(repoRoot, store) { +function writeStore(repoRoot, store, { beforeRename, afterRename } = {}) { const dir = bindingStoreDir(repoRoot); const path = bindingStorePath(repoRoot); const temporary = join(dir, `.bindings.json.${randomBytes(12).toString("hex")}`); @@ -94,7 +94,9 @@ function writeStore(repoRoot, store) { } finally { closeSync(fd); } + beforeRename?.(); renameSync(temporary, path); + afterRename?.(); const directory = openSync(dir, constants.O_RDONLY); try { fsyncSync(directory); } finally { closeSync(directory); } } catch (error) { @@ -103,16 +105,20 @@ function writeStore(repoRoot, store) { } } -export function writeBinding(repoRoot, id, logicalPath, boundAt) { +// Transactional callers may compose a data-file publication with this write +// while already holding withRepoStateLock. Ordinary callers use writeBinding. +export function writeBindingWithinRepoStateLock(repoRoot, id, logicalPath, boundAt, options) { const safeId = ovenId(id); if (typeof logicalPath !== "string" || logicalPath.length === 0) throw new Error("Oven binding path must be a non-empty string."); if (!isTimestamp(boundAt)) throw new Error("Oven binding timestamp must be a valid ISO timestamp."); - return withRepoStateLock(repoRoot, () => { - const store = mutableBindingStore(repoRoot); - store.bindings[safeId] = { path: logicalPath, boundAt }; - writeStore(repoRoot, store); - return { path: bindingStorePath(repoRoot), binding: store.bindings[safeId] }; - }); + const store = mutableBindingStore(repoRoot); + store.bindings[safeId] = { path: logicalPath, boundAt }; + writeStore(repoRoot, store, options); + return { path: bindingStorePath(repoRoot), binding: store.bindings[safeId] }; +} + +export function writeBinding(repoRoot, id, logicalPath, boundAt) { + return withRepoStateLock(repoRoot, () => writeBindingWithinRepoStateLock(repoRoot, id, logicalPath, boundAt)); } // Producers that establish a durable discovery root must not silently replace a diff --git a/src/server/oven-data-store.mjs b/src/server/oven-data-store.mjs new file mode 100644 index 0000000..73b84fc --- /dev/null +++ b/src/server/oven-data-store.mjs @@ -0,0 +1,172 @@ +import { randomBytes } from "node:crypto"; +import { + closeSync, + constants, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, posix, resolve } from "node:path"; +import { ovenId } from "../ovens/oven-contract.mjs"; +import { + bindingStorePath, + readBindingStore, + writeBindingWithinRepoStateLock, +} from "./oven-bindings.mjs"; +import { containedJoin, withRepoStateLock } from "./repo-state.mjs"; + +export const OVEN_DATA_MAX_BYTES = 64 * 1024 * 1024; + +function canonicalLogicalPath(id) { + return posix.join(".local", "burnlist", "data", `${ovenId(id)}.json`); +} + +export function canonicalOvenDataPath(repoRoot, id) { + return containedJoin(repoRoot, "data", `${ovenId(id)}.json`); +} + +function isTimestamp(value) { + return typeof value === "string" + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) + && !Number.isNaN(Date.parse(value)); +} + +function validatedBytes(serializedJson) { + if (typeof serializedJson !== "string") throw new Error("Validated Oven data must be serialized JSON text."); + const bytes = Buffer.from(serializedJson, "utf8"); + if (bytes.length > OVEN_DATA_MAX_BYTES) { + throw new Error(`Validated Oven data exceeds the ${OVEN_DATA_MAX_BYTES} byte limit.`); + } + try { + JSON.parse(serializedJson); + } catch (error) { + throw new Error(`Validated Oven data must be valid JSON: ${error.message}`); + } + return bytes; +} + +function fsyncDirectory(path) { + const fd = openSync(path, constants.O_RDONLY); + try { fsyncSync(fd); } finally { closeSync(fd); } +} + +function ensureDataDirectory(repoRoot, id) { + const path = canonicalOvenDataPath(repoRoot, id); + const directory = dirname(path); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + canonicalOvenDataPath(repoRoot, id); + const stat = lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Canonical Oven data directory must be a real directory: ${directory}`); + } + return path; +} + +function snapshot(path) { + try { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Transaction target must be a regular file: ${path}`); + return { exists: true, bytes: readFileSync(path), mode: stat.mode & 0o777 }; + } catch (error) { + if (error?.code === "ENOENT") return { exists: false }; + throw error; + } +} + +function publishFile(path, bytes, { mode = 0o600, beforeRename, afterRename } = {}) { + const directory = dirname(path); + const temporary = `${path}.${randomBytes(12).toString("hex")}.tmp`; + let fd; + try { + fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode); + writeFileSync(fd, bytes); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + beforeRename?.(); + renameSync(temporary, path); + afterRename?.(); + fsyncDirectory(directory); + } catch (error) { + if (fd !== undefined) closeSync(fd); + rmSync(temporary, { force: true }); + throw error; + } +} + +function restore(path, prior) { + if (prior.exists) { + publishFile(path, prior.bytes, { mode: prior.mode }); + return; + } + rmSync(path, { force: true }); + fsyncDirectory(dirname(path)); +} + +function rollback(error, steps) { + const failures = []; + for (const step of steps) { + try { step(); } catch (failure) { failures.push(failure); } + } + if (failures.length === 0) throw error; + throw new AggregateError( + [error, ...failures], + `Oven data transaction failed and ${failures.length} rollback step(s) also failed.`, + ); +} + +export function publishOvenData(repoRoot, id, serializedJson, boundAt, { hooks = {}, commit } = {}) { + const safeId = ovenId(id); + const bytes = validatedBytes(serializedJson); + if (!isTimestamp(boundAt)) throw new Error("Oven binding timestamp must be a valid ISO timestamp."); + const logicalPath = canonicalLogicalPath(safeId); + const root = resolve(repoRoot); + + return withRepoStateLock(root, () => { + const dataPath = ensureDataDirectory(root, safeId); + const storePath = bindingStorePath(root); + const priorData = snapshot(dataPath); + const priorBindings = snapshot(storePath); + const currentBinding = readBindingStore(root).bindings[safeId]; + if (priorData.exists && priorData.bytes.equals(bytes) && currentBinding?.path === logicalPath) { + commit?.(); + return { changed: false, dataPath, bindingPath: storePath, binding: currentBinding }; + } + + let dataPublished = false; + let bindingPublished = false; + try { + publishFile(dataPath, bytes, { + beforeRename: hooks.beforeDataRename, + afterRename() { + dataPublished = true; + hooks.afterDataRename?.(); + }, + }); + hooks.beforeBindingPublish?.(); + const bindingResult = writeBindingWithinRepoStateLock(root, safeId, logicalPath, boundAt, { + afterRename() { + bindingPublished = true; + hooks.afterBindingRename?.(); + }, + }); + commit?.(); + return { + changed: true, + dataPath, + bindingPath: bindingResult.path, + binding: bindingResult.binding, + }; + } catch (error) { + const steps = []; + if (bindingPublished) steps.push(() => restore(storePath, priorBindings)); + if (dataPublished) steps.push(() => restore(dataPath, priorData)); + return rollback(error, steps); + } + }); +} diff --git a/src/server/oven-data-store.test.mjs b/src/server/oven-data-store.test.mjs new file mode 100644 index 0000000..e115910 --- /dev/null +++ b/src/server/oven-data-store.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { bindingStorePath, readBindingStore, writeBinding } from "./oven-bindings.mjs"; +import { + canonicalOvenDataPath, + publishOvenData, +} from "./oven-data-store.mjs"; + +const FIRST_AT = "2026-07-22T00:00:00.000Z"; +const SECOND_AT = "2026-07-22T00:01:00.000Z"; +const firstBytes = '{"version":1,"items":["old"]}\n'; +const secondBytes = '{"version":2,"items":["new"]}\n'; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "burnlist-oven-data-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} + +function mode(path) { + return lstatSync(path).mode & 0o777; +} + +test("fresh publication writes private canonical data and its binding", (t) => { + const root = fixture(t); + const result = publishOvenData(root, "sample-oven", firstBytes, FIRST_AT); + + assert.equal(result.changed, true); + assert.equal(result.dataPath, canonicalOvenDataPath(root, "sample-oven")); + assert.equal(readFileSync(result.dataPath, "utf8"), firstBytes); + assert.equal(mode(result.dataPath), 0o600); + assert.equal(mode(dirname(result.dataPath)), 0o700); + assert.deepEqual(readBindingStore(root).bindings["sample-oven"], { + path: ".local/burnlist/data/sample-oven.json", + boundAt: FIRST_AT, + }); + assert.equal(mode(bindingStorePath(root)), 0o600); + assert.deepEqual(readdirSync(dirname(result.dataPath)).sort(), ["sample-oven.json"]); +}); + +test("publishing identical canonical state is idempotent", (t) => { + const root = fixture(t); + publishOvenData(root, "sample-oven", firstBytes, FIRST_AT); + const beforeData = lstatSync(canonicalOvenDataPath(root, "sample-oven")); + const beforeBinding = readFileSync(bindingStorePath(root), "utf8"); + + const result = publishOvenData(root, "sample-oven", firstBytes, SECOND_AT); + + assert.equal(result.changed, false); + assert.equal(lstatSync(result.dataPath).ino, beforeData.ino); + assert.equal(readFileSync(bindingStorePath(root), "utf8"), beforeBinding); + assert.equal(result.binding.boundAt, FIRST_AT); +}); + +test("publication replaces a noncanonical binding without touching its source", (t) => { + const root = fixture(t); + const oldPath = join(root, "reports", "old.json"); + mkdirSync(dirname(oldPath), { recursive: true }); + writeFileSync(oldPath, firstBytes); + writeBinding(root, "sample-oven", "reports/old.json", FIRST_AT); + + publishOvenData(root, "sample-oven", secondBytes, SECOND_AT); + + assert.equal(readFileSync(oldPath, "utf8"), firstBytes); + assert.equal(readFileSync(canonicalOvenDataPath(root, "sample-oven"), "utf8"), secondBytes); + assert.deepEqual(readBindingStore(root).bindings["sample-oven"], { + path: ".local/burnlist/data/sample-oven.json", + boundAt: SECOND_AT, + }); +}); + +test("publication boundaries expose only complete old or new files", (t) => { + const root = fixture(t); + publishOvenData(root, "sample-oven", firstBytes, FIRST_AT); + const dataPath = canonicalOvenDataPath(root, "sample-oven"); + const observed = []; + + publishOvenData(root, "sample-oven", secondBytes, SECOND_AT, { hooks: { + beforeDataRename() { observed.push(readFileSync(dataPath, "utf8")); }, + afterDataRename() { observed.push(readFileSync(dataPath, "utf8")); }, + beforeBindingPublish() { observed.push(readFileSync(dataPath, "utf8")); }, + afterBindingRename() { observed.push(readFileSync(dataPath, "utf8")); }, + } }); + + assert.deepEqual(observed, [firstBytes, secondBytes, secondBytes, secondBytes]); +}); + +for (const boundary of [ + "beforeDataRename", + "afterDataRename", + "beforeBindingPublish", + "afterBindingRename", +]) { + test(`an injected ${boundary} failure restores exact prior bytes and binding`, (t) => { + const root = fixture(t); + publishOvenData(root, "sample-oven", firstBytes, FIRST_AT); + const dataPath = canonicalOvenDataPath(root, "sample-oven"); + const bindingPath = bindingStorePath(root); + const priorData = readFileSync(dataPath); + const priorBinding = readFileSync(bindingPath); + + assert.throws(() => publishOvenData(root, "sample-oven", secondBytes, SECOND_AT, { + hooks: { [boundary]() { throw new Error(`injected ${boundary}`); } }, + }), new RegExp(`injected ${boundary}`, "u")); + + assert.deepEqual(readFileSync(dataPath), priorData); + assert.deepEqual(readFileSync(bindingPath), priorBinding); + assert.deepEqual(readdirSync(dirname(dataPath)).sort(), ["sample-oven.json"]); + }); +} + +test("a failed fresh publication leaves no data or binding", (t) => { + const root = fixture(t); + assert.throws(() => publishOvenData(root, "sample-oven", firstBytes, FIRST_AT, { + hooks: { afterBindingRename() { throw new Error("fresh binding failure"); } }, + }), /fresh binding failure/u); + assert.equal(existsSync(canonicalOvenDataPath(root, "sample-oven")), false); + assert.equal(existsSync(bindingStorePath(root)), false); +}); + +test("a failed transaction commit restores existing and fresh publication state", (t) => { + const existing = fixture(t); + publishOvenData(existing, "sample-oven", firstBytes, FIRST_AT); + const priorData = readFileSync(canonicalOvenDataPath(existing, "sample-oven")); + const priorBinding = readFileSync(bindingStorePath(existing)); + + assert.throws(() => publishOvenData(existing, "sample-oven", secondBytes, SECOND_AT, { + commit() { throw new Error("composite commit failed"); }, + }), /composite commit failed/u); + assert.deepEqual(readFileSync(canonicalOvenDataPath(existing, "sample-oven")), priorData); + assert.deepEqual(readFileSync(bindingStorePath(existing)), priorBinding); + + const fresh = fixture(t); + assert.throws(() => publishOvenData(fresh, "sample-oven", firstBytes, FIRST_AT, { + commit() { throw new Error("fresh composite commit failed"); }, + }), /fresh composite commit failed/u); + assert.equal(existsSync(canonicalOvenDataPath(fresh, "sample-oven")), false); + assert.equal(existsSync(bindingStorePath(fresh)), false); +}); + +test("publication rejects invalid input and contained-path escapes before mutation", (t) => { + const root = fixture(t); + assert.throws(() => publishOvenData(root, "../escape", firstBytes, FIRST_AT), /lowercase slug/u); + assert.throws(() => publishOvenData(root, "sample-oven", "not JSON", FIRST_AT), /valid JSON/u); + + const outside = fixture(t); + const dataDir = join(root, ".local", "burnlist", "data"); + mkdirSync(dirname(dataDir), { recursive: true }); + symlinkSync(outside, dataDir); + assert.throws(() => publishOvenData(root, "sample-oven", firstBytes, FIRST_AT), /escapes/u); + assert.deepEqual(readdirSync(outside), []); + assert.equal(existsSync(bindingStorePath(root)), false); +}); + +test("concurrent publishers leave one complete payload and canonical binding", async (t) => { + const root = fixture(t); + const ready = join(root, "ready"); + const go = join(root, "go"); + const moduleUrl = new URL("./oven-data-store.mjs", import.meta.url).href; + const child = (bytes, at) => new Promise((resolveChild, reject) => { + const source = `import { existsSync, writeFileSync } from "node:fs"; import { publishOvenData } from ${JSON.stringify(moduleUrl)}; const [root, bytes, at, ready, go] = process.argv.slice(1); writeFileSync(ready + "-" + process.pid, ""); while (!existsSync(go)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); process.stdout.write(JSON.stringify(publishOvenData(root, "sample-oven", bytes, at)));`; + const processChild = spawn(process.execPath, ["--input-type=module", "--eval", source, root, bytes, at, ready, go], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + processChild.stdout.on("data", (chunk) => { stdout += chunk; }); + processChild.stderr.on("data", (chunk) => { stderr += chunk; }); + processChild.on("error", reject); + processChild.on("exit", (status) => status === 0 ? resolveChild(JSON.parse(stdout)) : reject(new Error(stderr))); + }); + const first = child(firstBytes, FIRST_AT); + const second = child(secondBytes, SECOND_AT); + for (let attempt = 0; attempt < 100 && readdirSync(root).filter((name) => name.startsWith("ready-")).length < 2; attempt += 1) { + await new Promise((resolveWait) => setTimeout(resolveWait, 5)); + } + writeFileSync(go, "go"); + await Promise.all([first, second]); + + const data = readFileSync(canonicalOvenDataPath(root, "sample-oven"), "utf8"); + assert.ok(data === firstBytes || data === secondBytes); + assert.equal(readBindingStore(root).bindings["sample-oven"].path, ".local/burnlist/data/sample-oven.json"); + assert.deepEqual(readdirSync(join(root, ".local", "burnlist", "data")), ["sample-oven.json"]); +}); From b13466b560a7def0134125f62ccb262b98a02bac Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 22 Jul 2026 01:34:16 +0200 Subject: [PATCH 13/21] feat: add validated oven set and use flows (260721-002) --- src/cli/oven-cli.mjs | 34 +++++++- src/cli/oven-set-cli.test.mjs | 150 ++++++++++++++++++++++++++++++++++ src/cli/oven-set.mjs | 62 ++++++++++++++ src/cli/oven-storage.mjs | 8 +- src/cli/oven-use.mjs | 79 ++++++++++++++++++ src/cli/oven-use.test.mjs | 139 +++++++++++++++++++++++++++++++ src/server/oven-vendor.mjs | 24 ++---- 7 files changed, 475 insertions(+), 21 deletions(-) create mode 100644 src/cli/oven-set-cli.test.mjs create mode 100644 src/cli/oven-set.mjs create mode 100644 src/cli/oven-use.mjs create mode 100644 src/cli/oven-use.test.mjs diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index aca97de..7eb7d3e 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -17,7 +17,9 @@ import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from import { resolveCustomOvensDir } from "../server/oven-storage.mjs"; import { readVendoredOven, vendoredOvenPath, writeVendoredOven } from "../server/oven-vendor.mjs"; import { renderOvenTree, sourceTable } from "./oven-cli-render.mjs"; +import { setOvenDataFromCli } from "./oven-set.mjs"; import { createOvenCatalog, persistOven, resolvePackageInput } from "./oven-storage.mjs"; +import { useShippedOven } from "./oven-use.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; // ── argv ──────────────────────────────────────────────────────────────────── @@ -134,6 +136,8 @@ const HELP = `burnlist oven — author and inspect Ovens Usage: burnlist oven list [--json] burnlist oven view [--json] + burnlist oven use [--repo ] [--force] + burnlist oven set [--repo ] burnlist oven bind [--repo ] burnlist oven unbind [--repo ] burnlist oven bindings [--repo ] @@ -161,12 +165,16 @@ Options: --payload Optional compact JSON event payload. --ovens-dir

Custom Oven storage (default .local/burnlist/ovens). --unsafe-ovens-dir Permit --ovens-dir outside repo-local state. - --force On create or adopt, replace an existing Oven. + --force On create, adopt, or use, replace an existing Oven. --json Machine-readable output for list/view. Custom Ovens live under ignored local state and only affect future Runs. Create scaffolds a minimal .oven source when --oven, --dir, and --package omit one. -Built-in Ovens are read-only; this command never executes Oven instructions.`; +Built-in Ovens are read-only; this command never executes Oven instructions. +Use adopts a shipped Oven and binds only an existing, validated example/data.json. +Set validates first with the same runtime validator, then atomically publishes +.local/burnlist/data/.json and its binding. Custom Ovens without a runtime +validator receive shape-only source-pointer validation, which does not prove truth.`; function main() { try { @@ -256,6 +264,28 @@ try { return; } + if (subcommand === "set") { + const result = setOvenDataFromCli({ positionals, repoRoot: bindingRepo(), launchCwd, findOven }); + for (const warning of result.warnings) console.warn(`burnlist oven: warning: ${warning}`); + console.log(result.output); + return; + } + + if (subcommand === "use") { + const [id, ...extra] = positionals; + if (!id || extra.length > 0) fail("Usage: burnlist oven use [--repo ] [--force]"); + const result = useShippedOven({ + id, + repoRoot: repoRoot(), + builtInOvensDir, + readOvenDir, + force: flags.has("force"), + }); + for (const warning of result.warnings) console.warn(`burnlist oven: warning: ${warning}`); + console.log(result.output); + return; + } + if (subcommand === "event") { const id = positionals[0]; const eventFlag = (name) => { diff --git a/src/cli/oven-set-cli.test.mjs b/src/cli/oven-set-cli.test.mjs new file mode 100644 index 0000000..a9d783b --- /dev/null +++ b/src/cli/oven-set-cli.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { buildPayload } from "../../ovens/differential-testing/example/adapter.mjs"; +import { readBindingStore } from "../server/oven-bindings.mjs"; +import { canonicalOvenDataPath } from "../server/oven-data-store.mjs"; + +const packageRoot = resolve(new URL("../..", import.meta.url).pathname); +const binPath = join(packageRoot, "bin", "burnlist.mjs"); + +function fixture(t, { ignored = true } = {}) { + const root = mkdtempSync(join(tmpdir(), "burnlist-oven-set-")); + const repo = join(root, "repo"); + mkdirSync(repo); + execFileSync("git", ["init", "-q"], { cwd: repo }); + if (ignored) writeFileSync(join(repo, ".gitignore"), ".local/\n"); + t.after(() => rmSync(root, { recursive: true, force: true })); + return repo; +} + +function runResult(repo, args, options = {}) { + return spawnSync(process.execPath, [binPath, ...args], { + cwd: repo, + encoding: "utf8", + ...options, + }); +} + +function run(repo, ...args) { + const result = runResult(repo, args); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result.stdout; +} + +function serialized(payload) { + return `${JSON.stringify(payload, null, 2)}\n`; +} + +function customOven(repo) { + const root = join(repo, ".local", "burnlist", "ovens", "custom-shape"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "instructions.md"), "# Custom Shape\n\nA pointer-only test Oven.\n"); + writeFileSync(join(root, "custom-shape.oven"), ` + + +`); +} + +function validDifferentialPayload() { + const example = join(packageRoot, "ovens", "differential-testing", "example"); + return buildPayload( + JSON.parse(readFileSync(join(example, "reference.json"), "utf8")), + JSON.parse(readFileSync(join(example, "candidate.json"), "utf8")), + ); +} + +test("oven set reads a file for a repo-selected vendored built-in", (t) => { + const repo = fixture(t); + const input = join(repo, "payload.json"); + const payload = { current: { title: "From a file" }, list: [1, 2] }; + writeFileSync(input, JSON.stringify(payload)); + run(repo, "oven", "adopt", "checklist", "--repo", repo); + + const output = run(repo, "oven", "set", "checklist", input, "--repo", repo); + const dataPath = canonicalOvenDataPath(repo, "checklist"); + assert.match(output, /Set Oven checklist data/u); + assert.ok(output.includes(`Data: ${dataPath}`)); + assert.ok(output.includes(`Binding: ${join(repo, ".local", "burnlist", "bindings.json")}`)); + assert.equal(readFileSync(dataPath, "utf8"), serialized(payload)); + assert.equal(readBindingStore(repo).bindings.checklist.path, ".local/burnlist/data/checklist.json"); + + const beforeBinding = readFileSync(join(repo, ".local", "burnlist", "bindings.json"), "utf8"); + run(repo, "oven", "set", "checklist", JSON.stringify(payload), "--repo", repo); + assert.equal(readFileSync(join(repo, ".local", "burnlist", "bindings.json"), "utf8"), beforeBinding); +}); + +test("oven set reads bounded JSON from stdin", (t) => { + const repo = fixture(t); + const payload = { from: "stdin", ok: true }; + const result = runResult(repo, ["oven", "set", "checklist", "-", "--repo", repo], { + input: JSON.stringify(payload), + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(canonicalOvenDataPath(repo, "checklist"), "utf8"), serialized(payload)); +}); + +test("custom set warns that validation is shape-only and rejects missing pointers", (t) => { + const repo = fixture(t); + customOven(repo); + const accepted = runResult(repo, [ + "oven", "set", "custom-shape", '{"summary":{"value":42}}', "--repo", repo, + ]); + assert.equal(accepted.status, 0, accepted.stderr); + assert.match(`${accepted.stdout}${accepted.stderr}`, /shape-only/u); + const dataPath = canonicalOvenDataPath(repo, "custom-shape"); + const priorData = readFileSync(dataPath); + const priorBinding = readFileSync(join(repo, ".local", "burnlist", "bindings.json")); + + const rejected = runResult(repo, [ + "oven", "set", "custom-shape", '{"summary":{}}', "--repo", repo, + ]); + assert.notEqual(rejected.status, 0); + assert.match(rejected.stderr, /\/summary\/value.*does not resolve/u); + assert.deepEqual(readFileSync(dataPath), priorData); + assert.deepEqual(readFileSync(join(repo, ".local", "burnlist", "bindings.json")), priorBinding); +}); + +test("runtime-invalid built-in data is rejected before fresh or replacement mutation", (t) => { + const freshRepo = fixture(t); + const invalid = '{"schema":"burnlist-differential-testing-data@1"}'; + const fresh = runResult(freshRepo, [ + "oven", "set", "differential-testing", invalid, "--repo", freshRepo, + ]); + assert.notEqual(fresh.status, 0); + assert.match(fresh.stderr, /Differential Testing data|scenarioCatalog|publishedAt/u); + assert.equal(existsSync(canonicalOvenDataPath(freshRepo, "differential-testing")), false); + assert.equal(Object.hasOwn(readBindingStore(freshRepo).bindings, "differential-testing"), false); + + const existingRepo = fixture(t); + const validPath = join(existingRepo, "valid.json"); + writeFileSync(validPath, JSON.stringify(validDifferentialPayload())); + run(existingRepo, "oven", "set", "differential-testing", validPath, "--repo", existingRepo); + const dataPath = canonicalOvenDataPath(existingRepo, "differential-testing"); + const bindingPath = join(existingRepo, ".local", "burnlist", "bindings.json"); + const priorData = readFileSync(dataPath); + const priorBinding = readFileSync(bindingPath); + const replacement = runResult(existingRepo, [ + "oven", "set", "differential-testing", invalid, "--repo", existingRepo, + ]); + assert.notEqual(replacement.status, 0); + assert.deepEqual(readFileSync(dataPath), priorData); + assert.deepEqual(readFileSync(bindingPath), priorBinding); +}); + +test("producer-managed and unignored targets fail without data state", (t) => { + const repo = fixture(t); + const managed = runResult(repo, ["oven", "set", "streaming-diff", "{}", "--repo", repo]); + assert.notEqual(managed.status, 0); + assert.match(managed.stderr, /producer-managed/u); + assert.equal(existsSync(canonicalOvenDataPath(repo, "streaming-diff")), false); + + const unignored = fixture(t, { ignored: false }); + const result = runResult(unignored, ["oven", "set", "checklist", "{}", "--repo", unignored]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not git-ignored/u); + assert.equal(existsSync(canonicalOvenDataPath(unignored, "checklist")), false); +}); diff --git a/src/cli/oven-set.mjs b/src/cli/oven-set.mjs new file mode 100644 index 0000000..0b0aff2 --- /dev/null +++ b/src/cli/oven-set.mjs @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateOvenData } from "../ovens/oven-data-validate.mjs"; +import { canonicalOvenDataPath, OVEN_DATA_MAX_BYTES, publishOvenData } from "../server/oven-data-store.mjs"; +import { readVendoredOven, vendoredOvenPath } from "../server/oven-vendor.mjs"; +import { assertGitIgnored } from "./git-ignore.mjs"; +import { readBoundedInput } from "./oven-storage.mjs"; + +function selectedOven(repoRoot, id, findOven) { + const vendored = readVendoredOven(repoRoot, id); + if (vendored) return { ...vendored, builtIn: true, path: vendoredOvenPath(repoRoot, id) }; + return findOven(id); +} + +function dataInput(spec, launchCwd) { + let source; + if (spec === "-" || existsSync(resolve(launchCwd, spec))) { + source = readBoundedInput(spec, OVEN_DATA_MAX_BYTES, "Oven data"); + } else { + if (Buffer.byteLength(spec, "utf8") > OVEN_DATA_MAX_BYTES) { + throw new Error(`Oven data exceeds the ${OVEN_DATA_MAX_BYTES} byte limit.`); + } + source = spec; + } + try { + return JSON.parse(source); + } catch (error) { + throw new Error(`Oven data input must be valid JSON: ${error.message}`); + } +} + +function invalidData(id, errors) { + const details = errors.map((error) => ` ${error.path}: ${error.message}`).join("\n"); + return new Error(`Oven ${id} data validation failed:\n${details}`); +} + +export function setOvenDataFromCli({ positionals, repoRoot, launchCwd, findOven, now = () => new Date() }) { + const [id, input, ...extra] = positionals; + if (!id || input === undefined || extra.length > 0) { + throw new Error("Usage: burnlist oven set [--repo ]"); + } + const oven = selectedOven(repoRoot, id, findOven); + if (!oven) throw new Error(`Unknown Oven "${id}". Run \`burnlist oven list\`.`); + const payload = dataInput(input, launchCwd); + const dataPath = canonicalOvenDataPath(repoRoot, oven.id); + const validation = validateOvenData(oven, payload, { + bindingPath: dataPath, + maxOvenDataBytes: OVEN_DATA_MAX_BYTES, + }); + if (!validation.ok) throw invalidData(oven.id, validation.errors); + assertGitIgnored(repoRoot, dataPath); + const saved = publishOvenData( + repoRoot, + oven.id, + `${JSON.stringify(payload, null, 2)}\n`, + now().toISOString(), + ); + return { + warnings: validation.warnings, + output: `Set Oven ${oven.id} data.\nData: ${saved.dataPath}\nBinding: ${saved.bindingPath}`, + }; +} diff --git a/src/cli/oven-storage.mjs b/src/cli/oven-storage.mjs index 7ad7fe4..a58d123 100644 --- a/src/cli/oven-storage.mjs +++ b/src/cli/oven-storage.mjs @@ -133,7 +133,7 @@ export function createOvenCatalog({ builtInOvensDir, customOvensDir, customRepoR }; } -function readInput(spec, maxBytes, label) { +export function readBoundedInput(spec, maxBytes, label) { if (spec === "-") { const chunks = []; let total = 0; @@ -152,15 +152,15 @@ function readInput(spec, maxBytes, label) { export function resolvePackageInput({ flags, positionals, scaffold = false }) { const pkg = {}; - if (flags.has("package")) Object.assign(pkg, JSON.parse(readInput(flags.get("package"), OVEN_PACKAGE_MAX_BYTES, "Oven package"))); + if (flags.has("package")) Object.assign(pkg, JSON.parse(readBoundedInput(flags.get("package"), OVEN_PACKAGE_MAX_BYTES, "Oven package"))); const id = ovenId(positionals[0] ?? pkg.id ?? flags.get("id") ?? ""); if (flags.has("dir")) { const dir = resolve(flags.get("dir")); pkg.instructions = readTextFileWithLimit(join(dir, "instructions.md"), OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); pkg.oven = readTextFileWithLimit(join(dir, `${id}.oven`), OVEN_SOURCE_MAX_BYTES, "Oven source"); } - if (flags.has("instructions")) pkg.instructions = readInput(flags.get("instructions"), OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); - if (flags.has("oven")) pkg.oven = readInput(flags.get("oven"), OVEN_SOURCE_MAX_BYTES, "Oven source"); + if (flags.has("instructions")) pkg.instructions = readBoundedInput(flags.get("instructions"), OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); + if (flags.has("oven")) pkg.oven = readBoundedInput(flags.get("oven"), OVEN_SOURCE_MAX_BYTES, "Oven source"); const name = flags.has("name") ? String(flags.get("name")).trim() : String(pkg.name ?? "").trim(); if (pkg.instructions === undefined) throw new Error("Provide instructions via --instructions, --package, or --dir."); diff --git a/src/cli/oven-use.mjs b/src/cli/oven-use.mjs new file mode 100644 index 0000000..63190d1 --- /dev/null +++ b/src/cli/oven-use.mjs @@ -0,0 +1,79 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { validateOvenData } from "../ovens/oven-data-validate.mjs"; +import { canonicalOvenDataPath, OVEN_DATA_MAX_BYTES, publishOvenData } from "../server/oven-data-store.mjs"; +import { vendoredOvenPath, writeVendoredOven } from "../server/oven-vendor.mjs"; +import { assertGitIgnored } from "./git-ignore.mjs"; +import { readBoundedInput } from "./oven-storage.mjs"; + +function invalidData(id, errors) { + const details = errors.map((error) => ` ${error.path}: ${error.message}`).join("\n"); + return new Error(`Oven ${id} example data validation failed:\n${details}`); +} + +function examplePayload(path) { + const source = readBoundedInput(path, OVEN_DATA_MAX_BYTES, "Oven example data"); + try { + return JSON.parse(source); + } catch (error) { + throw new Error(`Oven example data must be valid JSON: ${error.message}`); + } +} + +function adoptedOutput(saved, path) { + return `Adopted Oven ${saved.id}@${saved.version} at ${path}`; +} + +export function useShippedOven({ + id, + repoRoot, + builtInOvensDir, + readOvenDir, + force = false, + now = () => new Date(), + writeVendor = writeVendoredOven, +} = {}) { + const shipped = readOvenDir(builtInOvensDir, id, true); + if (!shipped) throw new Error(`Oven ${id} is not a shipped built-in.`); + const targetPath = vendoredOvenPath(repoRoot, shipped.id); + if (existsSync(targetPath) && !force) { + throw new Error(`Oven ${shipped.id} is already vendored at ${targetPath}.`); + } + + const examplePath = join(builtInOvensDir, shipped.id, "example", "data.json"); + const timestamp = now(); + const adopt = () => writeVendor(repoRoot, { + id: shipped.id, + instructions: shipped.instructions, + oven: shipped.oven, + now: timestamp, + }); + if (!existsSync(examplePath)) { + const saved = adopt(); + return { + warnings: [], + output: `${adoptedOutput(saved, targetPath)}\nNo example/data.json is shipped; adopted without data.\nNext: burnlist oven set ${saved.id} --repo ${JSON.stringify(repoRoot)}`, + }; + } + + const payload = examplePayload(examplePath); + const dataPath = canonicalOvenDataPath(repoRoot, shipped.id); + const validation = validateOvenData(shipped, payload, { + bindingPath: dataPath, + maxOvenDataBytes: OVEN_DATA_MAX_BYTES, + }); + if (!validation.ok) throw invalidData(shipped.id, validation.errors); + assertGitIgnored(repoRoot, dataPath); + let savedOven; + const savedData = publishOvenData( + repoRoot, + shipped.id, + `${JSON.stringify(payload, null, 2)}\n`, + timestamp.toISOString(), + { commit() { savedOven = adopt(); } }, + ); + return { + warnings: validation.warnings, + output: `${adoptedOutput(savedOven, targetPath)}\nSet shipped example data for Oven ${shipped.id}.\nData: ${savedData.dataPath}\nBinding: ${savedData.bindingPath}`, + }; +} diff --git a/src/cli/oven-use.test.mjs b/src/cli/oven-use.test.mjs new file mode 100644 index 0000000..6fe33dc --- /dev/null +++ b/src/cli/oven-use.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; +import { checklistFixture } from "../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; +import { compileOven } from "../ovens/dsl/oven-compile.mjs"; +import { readBindingStore } from "../server/oven-bindings.mjs"; +import { canonicalOvenDataPath } from "../server/oven-data-store.mjs"; +import { vendoredOvenPath } from "../server/oven-vendor.mjs"; +import { useShippedOven } from "./oven-use.mjs"; + +const packageRoot = resolve(new URL("../..", import.meta.url).pathname); +const binPath = join(packageRoot, "bin", "burnlist.mjs"); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "burnlist-oven-use-")); + const repo = join(root, "repo"); + const builtIns = join(root, "built-ins"); + mkdirSync(repo); + mkdirSync(builtIns); + execFileSync("git", ["init", "-q"], { cwd: repo }); + writeFileSync(join(repo, ".gitignore"), ".local/\n"); + t.after(() => rmSync(root, { recursive: true, force: true })); + return { root, repo, builtIns }; +} + +function shipped(id) { + const directory = join(packageRoot, "ovens", id); + return { + id, + builtIn: true, + instructions: readFileSync(join(directory, "instructions.md"), "utf8"), + oven: readFileSync(join(directory, `${id}.oven`), "utf8"), + }; +} + +function installExample(context, id, payload) { + const directory = join(context.builtIns, id, "example"); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "data.json"), JSON.stringify(payload)); +} + +function callUse(context, id, options = {}) { + const oven = shipped(id); + return useShippedOven({ + id, + repoRoot: context.repo, + builtInOvensDir: context.builtIns, + readOvenDir: () => oven, + now: () => new Date("2026-07-22T00:00:00.000Z"), + ...options, + }); +} + +function runResult(repo, ...args) { + return spawnSync(process.execPath, [binPath, ...args], { cwd: repo, encoding: "utf8" }); +} + +async function renderChecklist(payload) { + const output = mkdtempSync(join(packageRoot, ".oven-use-render-")); + const runtimeOutput = join(output, "OvenRuntime.mjs"); + const adapterOutput = join(output, "checklist-adapter.mjs"); + const sourceDir = join(packageRoot, "dashboard", "src"); + try { + await Promise.all([ + build({ entryPoints: [join(sourceDir, "oven", "runtime", "OvenRuntime.tsx")], bundle: true, format: "esm", outfile: runtimeOutput, platform: "node", alias: { "@": sourceDir, "@lib": join(sourceDir, "lib"), "@oven": join(sourceDir, "oven") }, jsx: "automatic", packages: "external", target: "node18" }), + build({ entryPoints: [join(sourceDir, "lib", "checklist-adapter.ts")], bundle: true, format: "esm", outfile: adapterOutput, platform: "node", target: "node18" }), + ]); + const [{ OvenRuntime }, { adaptChecklist }] = await Promise.all([ + import(`${new URL(`file://${runtimeOutput}`).href}?test=${Date.now()}`), + import(`${new URL(`file://${adapterOutput}`).href}?test=${Date.now()}`), + ]); + const compiled = compileOven(shipped("checklist").oven); + assert.equal(compiled.ok, true); + return renderToStaticMarkup(createElement(OvenRuntime, { ir: compiled.ir, payload: adaptChecklist(payload) })); + } finally { + rmSync(output, { recursive: true, force: true }); + } +} + +test("oven use adopts without data when no exact shipped example exists", (t) => { + const context = fixture(t); + const result = runResult(context.repo, "oven", "use", "differential-testing", "--repo", context.repo); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(vendoredOvenPath(context.repo, "differential-testing")), true); + assert.equal(existsSync(canonicalOvenDataPath(context.repo, "differential-testing")), false); + assert.equal(Object.hasOwn(readBindingStore(context.repo).bindings, "differential-testing"), false); + assert.match(result.stdout, /No example\/data\.json is shipped; adopted without data/u); + assert.ok(result.stdout.includes(`burnlist oven set differential-testing --repo ${JSON.stringify(context.repo)}`)); + + const duplicate = runResult(context.repo, "oven", "use", "differential-testing", "--repo", context.repo); + assert.notEqual(duplicate.status, 0); + assert.match(duplicate.stderr, /already vendored/u); + const forced = runResult(context.repo, "oven", "use", "differential-testing", "--repo", context.repo, "--force"); + assert.equal(forced.status, 0, forced.stderr); + assert.equal(existsSync(canonicalOvenDataPath(context.repo, "differential-testing")), false); +}); + +test("oven use validates, transactionally installs, and renders exact example data", async (t) => { + const context = fixture(t); + const payload = checklistFixture; + installExample(context, "checklist", payload); + + const result = callUse(context, "checklist"); + const vendor = vendoredOvenPath(context.repo, "checklist"); + const data = canonicalOvenDataPath(context.repo, "checklist"); + assert.equal(readFileSync(data, "utf8"), `${JSON.stringify(payload, null, 2)}\n`); + assert.equal(readBindingStore(context.repo).bindings.checklist.path, ".local/burnlist/data/checklist.json"); + assert.deepEqual(readdirSync(vendor).sort(), ["checklist.oven", "instructions.md", "pin.json"]); + assert.match(result.output, /Adopted Oven checklist/u); + assert.ok(result.output.includes(`Data: ${data}`)); + const markup = await renderChecklist(JSON.parse(readFileSync(data, "utf8"))); + assert.match(markup, /2 of 2 tasks complete/u); + assert.match(markup, /Second event/u); +}); + +test("invalid or interrupted example setup leaves no partial install", (t) => { + const invalid = fixture(t); + installExample(invalid, "differential-testing", { schema: "burnlist-differential-testing-data@1" }); + assert.throws(() => callUse(invalid, "differential-testing"), /data validation failed/u); + assert.equal(existsSync(vendoredOvenPath(invalid.repo, "differential-testing")), false); + assert.equal(existsSync(canonicalOvenDataPath(invalid.repo, "differential-testing")), false); + assert.equal(Object.hasOwn(readBindingStore(invalid.repo).bindings, "differential-testing"), false); + + const interrupted = fixture(t); + installExample(interrupted, "checklist", { ok: true }); + assert.throws(() => callUse(interrupted, "checklist", { + writeVendor() { throw new Error("injected vendor failure"); }, + }), /injected vendor failure/u); + assert.equal(existsSync(vendoredOvenPath(interrupted.repo, "checklist")), false); + assert.equal(existsSync(canonicalOvenDataPath(interrupted.repo, "checklist")), false); + assert.equal(existsSync(dirname(canonicalOvenDataPath(interrupted.repo, "checklist"))), true); + assert.equal(Object.hasOwn(readBindingStore(interrupted.repo).bindings, "checklist"), false); +}); diff --git a/src/server/oven-vendor.mjs b/src/server/oven-vendor.mjs index 2999820..3786bb8 100644 --- a/src/server/oven-vendor.mjs +++ b/src/server/oven-vendor.mjs @@ -1,6 +1,7 @@ -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, realpathSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { ovenId, normalizeOvenPackage, ovenRevision } from "../ovens/oven-contract.mjs"; +import { atomicDirectory, withOvenPackageLock } from "./fs-safe.mjs"; function isWithin(parent, child) { const pathFromParent = relative(parent, child); @@ -111,21 +112,14 @@ export function writeVendoredOven(repoRoot, { id, instructions, oven, source = " source, pinnedAt: (now ?? new Date()).toISOString(), }; - const directory = assertVendoredOvenPath(repoRoot, safeId); const root = vendoredOvensDir(repoRoot); - let temporary; - try { - mkdirSync(root, { recursive: true }); - temporary = mkdtempSync(join(root, `${safeId}.tmp-`)); - writeFileSync(join(temporary, "instructions.md"), instructions); - writeFileSync(join(temporary, `${safeId}.oven`), oven); - writeFileSync(join(temporary, "pin.json"), `${JSON.stringify(pin, null, 2)}\n`); - rmSync(directory, { recursive: true, force: true }); - renameSync(temporary, directory); - temporary = null; - } finally { - if (temporary) rmSync(temporary, { recursive: true, force: true }); - } + mkdirSync(root, { recursive: true }); + assertVendoredOvenPath(repoRoot, safeId); + withOvenPackageLock(root, safeId, () => atomicDirectory(root, safeId, { + "instructions.md": instructions, + [`${safeId}.oven`]: oven, + "pin.json": `${JSON.stringify(pin, null, 2)}\n`, + }, { replace: true })); return { ...pkg, revision, pin }; } From d9f8e228ff4542c1eb4085581b1d756175d5062f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 22 Jul 2026 01:34:53 +0200 Subject: [PATCH 14/21] docs: document built-in oven data shapes and starters (260721-002) --- ovens/checklist/instructions.md | 11 +++++++- ovens/differential-testing/instructions.md | 18 +++++++++++++ ovens/model-lab/instructions.md | 12 +++++++++ ovens/performance-tracing/instructions.md | 14 ++++++++++ ovens/streaming-diff/instructions.md | 13 +++++++++- ovens/visual-parity/instructions.md | 13 ++++++++++ scripts/verify-oven-assertions.mjs | 30 ++++++++++++++++++++++ scripts/verify.mjs | 8 +++++- 8 files changed, 116 insertions(+), 3 deletions(-) diff --git a/ovens/checklist/instructions.md b/ovens/checklist/instructions.md index 9cac1d1..a9b5e82 100644 --- a/ovens/checklist/instructions.md +++ b/ovens/checklist/instructions.md @@ -2,7 +2,16 @@ `checklist.oven` is the declarative, read-only detail view for a Burnlist checklist. It renders the current task, progress KPIs, completion ledger, burn chart, and completed-event cards without changing canonical Burnlist state. -## Payload contract +## Data Shape + +- Input mode: `json-payload`. +- Runtime validator: `validateGenericJsonData`. +- Starter data: none. + +The runtime validator is the authority used by both `oven set` and the data +handler. It accepts any parsed JSON value; that establishes JSON readability, +not a field-shape or truth claim. No `example/data.json` is shipped, so `oven +use checklist` adopts the Oven without writing or binding data. The view binds `checklist-progress@1` data after `adaptChecklist(data)` in `dashboard/src/lib/checklist-adapter.ts`. The payload contains: diff --git a/ovens/differential-testing/instructions.md b/ovens/differential-testing/instructions.md index 329d4a0..0ab8b64 100644 --- a/ovens/differential-testing/instructions.md +++ b/ovens/differential-testing/instructions.md @@ -13,6 +13,24 @@ An adapter may publish either mode: When `exactSession.strategy` is `exact-first`, exact target selection fails closed. Missing, stale, failed, contradictory, or unbound exact evidence produces `blocked`. The adapter and validator must not fall back to field failure counts, first failing aggregate ticks, Changed, history, or visually prominent intervals. +## Data Shape + +- Input mode: `json-payload`. +- Runtime validator: `validateDifferentialTestingRuntimeData`. +- Starter data: none. + +The runtime validator is the authority used by both `oven set` and the render +handler. It validates the root `burnlist-differential-testing-data@1` document, +including `publishedAt`, adapter and trust metadata, `scenarioCatalog`, +event-driven `refresh`, reconciled `summary`, chronological `progress`, reverse +chronological `log`, aligned `fields`, and the optional `telemetry` and +`exactSession` surfaces described below. The engine's `data.schema.json` is +available to humans and agents, but JSON Schema is informational reference +documentation only; it is not the validation authority. The shipped +`example/reference.json` and `example/candidate.json` are adapter inputs, not a +renderable payload. There is no `example/data.json`, so `oven use +differential-testing` adopts without data. + ## State Contract Canonical state stays in project-owned captures, reports, retained runtime state, replay/profile data, exact artifacts, checker outputs, and source evidence. A project adapter maps compact facts from those artifacts into `burnlist-differential-testing-data@1`. Burnlist validates and renders only that normalized document. diff --git a/ovens/model-lab/instructions.md b/ovens/model-lab/instructions.md index 3fbebc0..3033472 100644 --- a/ovens/model-lab/instructions.md +++ b/ovens/model-lab/instructions.md @@ -6,6 +6,18 @@ Inspect one prepared PolyCSS model through the product's real mount path while r The live surface mounts one prepared model without runtime parsing, geometry construction, topology construction, material construction, asset construction, or LOD substitution. Frame changes update styles on the same DOM root and leaves. +## Data Shape + +- Input mode: `json-payload`. +- Runtime validator: `validateModelLabRuntimeData`. +- Starter data: none. + +The runtime validator is the authority used by both `oven set` and the render +handler. It requires a `burnlist-model-lab-data@1` document with `generatedAt`, +`project`, loopback `surface`, prepared `model`, and hash-bound `evidence`, plus +an optional reconciled `comparison`. There is no `example/data.json`, so `oven +use model-lab` adopts without data. + ## State Contract The bound document uses `burnlist-model-lab-data@1`. It identifies one loopback-hosted live surface, one prepared frameset, its selected frame, its stable topology hashes, the single `` leaf tag, `lodCount: 1`, zero runtime construction counters, and manifest/render-publication SHA-256 evidence. diff --git a/ovens/performance-tracing/instructions.md b/ovens/performance-tracing/instructions.md index d2494f4..a99b992 100644 --- a/ovens/performance-tracing/instructions.md +++ b/ovens/performance-tracing/instructions.md @@ -2,6 +2,20 @@ Performance Tracing renders retained browser-output timing evidence from a project-owned trace run. It shows frame pacing, synchronous step cost, budget checks, renderer trace groups, slow steps, browser identity, and source provenance without treating instrumentation as gameplay or visual-equivalence authority. +## Data Shape + +- Input mode: `json-payload`. +- Runtime validator: `validatePerformanceTracingRuntimeData`. +- Starter data: none. + +The runtime validator is the authority used by both `oven set` and the render +handler. It requires a `performance-tracing-oven@1` report with run identity, +trust boundary, metrics, browser and scenario, reconciled verdict checks, +artifacts, current source-file provenance, diagnostics, and optional retained +runs and history. Provenance files are re-hashed beside the bound report, so a +structurally valid but stale report is rejected. There is no +`example/data.json`, so `oven use performance-tracing` adopts without data. + ## State Contract The project publishes one atomic `performance-tracing-oven@1` JSON report. The report owns capture, deterministic replay, raw Chrome trace retention, samples, machine and browser provenance, and budget evaluation. Burnlist validates and renders that normalized report; it does not execute the trace command or rewrite project evidence. diff --git a/ovens/streaming-diff/instructions.md b/ovens/streaming-diff/instructions.md index 9750b42..e118f51 100644 --- a/ovens/streaming-diff/instructions.md +++ b/ovens/streaming-diff/instructions.md @@ -5,7 +5,18 @@ recently published, session-scoped pre-to-post diff cards. It renders the heading and diff cards through the `.oven` engine, byte-for-byte identical to the selected-feed component view. -## Payload contract +## Data Shape + +- Input mode: `producer-managed`. +- Runtime validator: `none`. +- Starter data: none. + +Streaming Diff does not accept one JSON document: `oven set streaming-diff` is +refused. Its runtime handler reads the producer-owned +`burnlist-streaming-diff-data@2` feed root, validates contained manifest/card +identity through the journal contract, and serves a selected snapshot or SSE +updates. There is no `example/data.json`, so `oven use streaming-diff` adopts +without data or a binding; the feed producer establishes its own binding. The view binds `burnlist-streaming-diff-data@2` after `adaptStreamingDiff(snapshot)`. The adapter provides the feed identity, update time, normalized cards, and the diff --git a/ovens/visual-parity/instructions.md b/ovens/visual-parity/instructions.md index 81bcb2d..97722fe 100644 --- a/ovens/visual-parity/instructions.md +++ b/ovens/visual-parity/instructions.md @@ -2,6 +2,19 @@ Visual Parity compares trusted reference and candidate frames as isolated render passes. Each domain declares whether it qualifies the current scenario (`target`) or remains visible diagnostic context (`context`), so unrelated render domains never contaminate one another. +## Data Shape + +- Input mode: `json-payload`. +- Runtime validator: `validateVisualParityRuntimeData`. +- Starter data: none. + +The runtime validator is the authority used by both `oven set` and the render +handler. It requires a `burnlist-visual-parity-data@1` document containing a +valid selected Differential Testing payload, 1-12 uniquely qualified `domains`, +and ordered `comparisons` with complete dimension-aligned screenshot triplets, +reconciled difference metrics, tolerances, and target-only verdicts. There is +no `example/data.json`, so `oven use visual-parity` adopts without data. + The project adapter publishes `burnlist-visual-parity-data@1`. A passing domain must satisfy its explicit calibrated channel, mean-delta, and changed-pixel bounds. Context domains remain visible and retain their own pass/fail state, but do not decide the target scenario verdict. Do not widen a tolerance to make a regression green. Calibrate only a deterministic renderer-boundary residual with a written rationale, preserve the zero-tolerance default, and keep gameplay/state authority in the linked Differential Testing payload. diff --git a/scripts/verify-oven-assertions.mjs b/scripts/verify-oven-assertions.mjs index 5addf41..7d25c18 100644 --- a/scripts/verify-oven-assertions.mjs +++ b/scripts/verify-oven-assertions.mjs @@ -50,3 +50,33 @@ export function assertBuiltInOven(repoRoot, id, expectedName) { process.exit(1); } } + +function containsJsonSchema(path) { + return readdirSync(path, { withFileTypes: true }).some((entry) => { + const entryPath = join(path, entry.name); + return entry.isDirectory() ? containsJsonSchema(entryPath) : entry.name.endsWith(".schema.json"); + }); +} + +export function assertBuiltInOvenDataDocs(repoRoot, id, { dataInput, validator }) { + const root = resolve(repoRoot, "ovens", id); + const instructions = readFileSync(join(root, "instructions.md"), "utf8"); + const exampleExists = existsSync(join(root, "example", "data.json")); + const expected = [ + "## Data Shape", + `- Input mode: \`${dataInput}\`.`, + `- Runtime validator: \`${validator ?? "none"}\`.`, + exampleExists ? "- Starter data: `example/data.json`." : "- Starter data: none.", + ]; + const missing = expected.filter((line) => !instructions.includes(line)); + const starterLines = instructions.split(/\r?\n/u).filter((line) => line.startsWith("- Starter data:")); + if (missing.length || starterLines.length !== 1) { + console.error(`Default oven ${id} instructions do not accurately declare data input, validator, and starter availability: ${missing.join(", ") || "duplicate starter declaration"}.`); + process.exit(1); + } + if (containsJsonSchema(root) + && !/JSON Schema is informational reference\s+documentation only; it is not the validation authority\./u.test(instructions)) { + console.error(`Default oven ${id} must label its JSON Schema as informational rather than authoritative.`); + process.exit(1); + } +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 7d56bb6..b7ad28e 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { assertBuiltInOven, assertBuiltInOvenSet, assertSkillSet } from "./verify-oven-assertions.mjs"; +import { assertBuiltInOven, assertBuiltInOvenDataDocs, assertBuiltInOvenSet, assertSkillSet } from "./verify-oven-assertions.mjs"; import { verificationSerialTestFiles, verificationTestFiles } from "./verify-test-files.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -411,6 +411,12 @@ assertBuiltInOven(repoRoot, "model-lab", "Model Lab"); assertBuiltInOven(repoRoot, "performance-tracing", "Performance Tracing"); assertBuiltInOven(repoRoot, "streaming-diff", "Streaming Diff"); assertBuiltInOven(repoRoot, "visual-parity", "Visual Parity"); +assertBuiltInOvenDataDocs(repoRoot, "checklist", { dataInput: "json-payload", validator: "validateGenericJsonData" }); +assertBuiltInOvenDataDocs(repoRoot, "differential-testing", { dataInput: "json-payload", validator: "validateDifferentialTestingRuntimeData" }); +assertBuiltInOvenDataDocs(repoRoot, "model-lab", { dataInput: "json-payload", validator: "validateModelLabRuntimeData" }); +assertBuiltInOvenDataDocs(repoRoot, "performance-tracing", { dataInput: "json-payload", validator: "validatePerformanceTracingRuntimeData" }); +assertBuiltInOvenDataDocs(repoRoot, "streaming-diff", { dataInput: "producer-managed" }); +assertBuiltInOvenDataDocs(repoRoot, "visual-parity", { dataInput: "json-payload", validator: "validateVisualParityRuntimeData" }); assertDifferentialTestingContractAssets(); assertPublishablePackage(); From 4861cc5dbc0d3c82cfba96383f899ac3461f03d4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 22 Jul 2026 01:35:14 +0200 Subject: [PATCH 15/21] docs: document oven use and set end to end (260721-002) --- bin/burnlist.mjs | 2 +- scripts/verify.mjs | 6 ++ skills/burnlist/SKILL.md | 2 +- skills/burnlist/references/creating-ovens.md | 11 ++-- skills/burnlist/references/designing-ovens.md | 13 +++- skills/burnlist/references/oven-authoring.md | 62 +++++++++++++++++-- skills/burnlist/references/oven-contract.md | 10 ++- src/cli/commands-help.test.mjs | 17 +++++ website/scripts/skill-content.mjs | 17 ++--- website/src/content/docs/cli.mdx | 21 ++++++- website/src/content/docs/getting-started.mdx | 2 +- website/src/content/docs/ovens.mdx | 18 ++++-- website/src/content/docs/ovens/authoring.mdx | 51 +++++++++++++-- .../content/docs/ovens/designing-ovens.mdx | 4 +- .../docs/ovens/differential-testing.mdx | 12 +++- .../src/content/docs/ovens/dsl-reference.mdx | 2 +- .../src/content/docs/ovens/oven-system.mdx | 8 ++- website/src/pages/index.astro | 2 +- 18 files changed, 219 insertions(+), 41 deletions(-) diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 9353343..37aa9bf 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -98,7 +98,7 @@ Usage: burnlist differential-testing sdk burnlist streaming-diff ... burnlist hooks [install|uninstall|status] [--agent codex,claude] [--untracked] (bare defaults to status) - burnlist oven ... + burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] burnlist ready [--repo ] diff --git a/scripts/verify.mjs b/scripts/verify.mjs index b7ad28e..7eb92a5 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -293,6 +293,12 @@ assertSourceIncludes("dashboard/src/components/ProjectGroup/BurnlistRow.tsx", "r assertSourceIncludes("dashboard/src/components/ProjectGroup/BurnlistRow.tsx", '', "Dashboard lifecycle filters do not use the shared line Tabs primitive."); assertSourceIncludes("bin/burnlist.mjs", "--oven-data ", "Burnlist CLI is missing read-only Oven data binding help."); +assertSourceIncludes("bin/burnlist.mjs", "oven [--repo ] [--force]", "Oven help is missing use syntax."); +assertSourceIncludes("src/cli/oven-cli.mjs", "burnlist oven set [--repo ]", "Oven help is missing set syntax."); +assertSourceIncludes("skills/burnlist/references/oven-authoring.md", "shape-only validation checks source pointers, not payload truth.", "Published skill guidance is missing the custom Oven validation boundary."); +assertSourceIncludes("website/src/content/docs/cli.mdx", ".local/burnlist/data/.json", "Website CLI docs are missing canonical Oven data publication."); +assertSourceIncludes("website/scripts/skill-content.mjs", "burnlist oven ", "Burnlist CLI is missing Differential Testing data validation help."); assertSourceIncludes("bin/burnlist.mjs", "differential-testing validate-bundle ", "Burnlist CLI is missing Differential Testing bundle validation help."); assertSourceIncludes("dashboard/src/components/AppHeader/AppHeader.tsx", 'className="dashboard-header"', "Dashboard header is missing its semantic style hook."); diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index d6b8b6d..da1b969 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -135,7 +135,7 @@ A burnlist in an unregistered repo is still visible when the dashboard is launch `New Oven` and `Run Burn` are explicit user-controlled local controller surfaces. For Oven contract, UI, validation, or Run-snapshot work, read `references/oven-contract.md`. Preserve its two-file declarative package and ownership boundary: custom Ovens may be created under ignored `.local/burnlist/ovens/` state and snapshotted under `.local/burnlist/runs/`, but neither surface may execute instructions, produce project data, own canonical project state, mutate Burnlists, import arbitrary UI code, or start an agent. -Ovens can also be authored and inspected from the CLI: `burnlist oven `. It writes custom Ovens or committed vendored copies, keeps shipped built-in Ovens read-only, reuses the same contract validation, and never executes instructions. Ovens carry an `id@version` identity and can be vendored and pinned per project with `adopt` and opt-in `upgrade`. `burnlist oven view ` renders the detail skeleton as a box-drawing grid for quick inspection. Read `references/oven-authoring.md` for the widget/format vocabulary and source-binding conventions. +Ovens can also be authored and inspected from the CLI: `burnlist oven `. `use` adopts a shipped Oven and installs only an existing validated example; `set` validates supplied JSON before atomically publishing ignored canonical data and its binding. Built-ins use the render handler's runtime validator, while custom Ovens without one warn that pointer validation is `shape-only`, not truth validation. The CLI writes custom Ovens or committed vendored copies, keeps shipped built-in Ovens read-only, and never executes instructions. Ovens carry an `id@version` identity and can be vendored and pinned per project with `adopt` and opt-in `upgrade`. `burnlist oven view ` renders the detail skeleton as a box-drawing grid for quick inspection. Read `references/oven-authoring.md` for exact `use`/`set` failure semantics, widget vocabulary, and source-binding conventions. Oven progress events are a separate observational surface under ignored repo-local state. They never replace Burnlist files or an Oven's proof artifacts. Checklist burns and Differential Testing worker iterations publish them automatically; future adapters use the same package API or `burnlist oven event`. The dashboard exposes one replayable `/api/events` feed across Ovens and repos. Read `references/oven-event-coordination.md` before using events to supervise worker tasks or wake a coordinator. diff --git a/skills/burnlist/references/creating-ovens.md b/skills/burnlist/references/creating-ovens.md index ecdd5ad..609be34 100644 --- a/skills/burnlist/references/creating-ovens.md +++ b/skills/burnlist/references/creating-ovens.md @@ -217,9 +217,9 @@ These components require exactly one `bind` for every named property: ## Keep Sources Clean -Normal Ovens use neither `class=` nor ``. The five shipped sources -(`streaming-diff`, `checklist`, `visual-parity`, `performance-tracing`, and -`differential-testing`) follow that rule: theme entries and shared components +Normal Ovens use neither `class=` nor ``. The six shipped sources +(`streaming-diff`, `checklist`, `visual-parity`, `performance-tracing`, +`model-lab`, and `differential-testing`) follow that rule: theme entries and shared components supply chrome and default classes. `class=` is an optional escape hatch only on `box`, `kpi-strip`, `kpi-item`, @@ -230,8 +230,9 @@ corresponding super-custom escape hatch. Keep both out of ordinary sources. First run `burnlist init` from the repository; then author an `instructions.md` with a level-one heading and a `.oven` file. Create the package with -`burnlist oven create --instructions --oven `, then bind the -JSON payload with `burnlist oven bind `. The CLI and dashboard details +`burnlist oven create --instructions --oven `, then validate +and snapshot the JSON payload with `burnlist oven set `. Use `bind` +instead for a producer-owned path that changes in place. The CLI and dashboard details are in `references/oven-authoring.md`. The `` value is the Oven's `id@version` identity, not its diff --git a/skills/burnlist/references/designing-ovens.md b/skills/burnlist/references/designing-ovens.md index b2cf6d8..cfc7450 100644 --- a/skills/burnlist/references/designing-ovens.md +++ b/skills/burnlist/references/designing-ovens.md @@ -95,10 +95,17 @@ Run the adapter, then wire and view the Oven: ```sh node migration-adapter.mjs # writes .local/burnlist/migration-status-data.json burnlist oven create migration-status --instructions instr.md --oven migration-status.oven -burnlist oven bind migration-status .local/burnlist/migration-status-data.json +burnlist oven set migration-status .local/burnlist/migration-status-data.json burnlist --scan-root . # dashboard renders the bound numbers ``` +For a custom Oven, `set` checks that every declared source pointer resolves and +warns that this is `shape-only`: it does not prove types, freshness, or truth. +It copies validated JSON to `.local/burnlist/data/.json` and atomically +updates the binding. If a long-running producer must update its own file in +place, use `burnlist oven bind` instead; binding records a path and does not +validate or copy its current content. + ### An unwired number is worse than no number Notice `legacyReadGreen: null`. The adapter does **not** yet read the legacy path, so it reports `null`—never a plausible-looking `true`. State the principle plainly: @@ -109,7 +116,7 @@ When a signal is not yet wired to reality, report `null` (or `"wired": false`) ### Who runs the adapter, and when -The **project owns and runs the adapter—Burnlist never does.** Nothing in Burnlist executes it: `burnlist oven bind` only records where the JSON lives, and the dashboard only reads it. Re-run the adapter after each batch of work, on a schedule, or in CI, so the Oven reflects current reality. A stale adapter is a stale Oven. +The **project owns and runs the adapter—Burnlist never does.** Nothing in Burnlist executes it: `burnlist oven set` validates and snapshots supplied JSON, `burnlist oven bind` only records where producer-managed data lives, and the dashboard only reads it. Re-run the adapter and `set` after each batch, or bind a producer-owned path that is refreshed on a schedule or in CI. A stale adapter is a stale Oven. ## Point a Burnlist item at an Oven number @@ -125,6 +132,6 @@ An Oven signal is only evidence if a Burnlist item's done/delete condition cites This link is **advisory evidence a human or agent reads—Burnlist does not execute the Oven or auto-verify the number.** `burnlist burn` and `burnlist --check` validate the Burnlist protocol and record the burn; they never open the Oven or read its bound JSON. The honesty is structural: the item's proof points at an objective, adapter-computed signal, so closing the item means opening the Oven (or its JSON) and confirming the number—not asserting "done." See [Proof Authority in Burnlist Creation](burnlist-creation.md) and [Oven Authoring](oven-authoring.md). -For the `.oven` grammar and a full worked example, see [Creating Ovens](creating-ovens.md). For creating and binding an Oven from the CLI—`burnlist oven create`, `burnlist oven bind`, and `burnlist oven view`—see [Oven Authoring](oven-authoring.md). +For the `.oven` grammar and a full worked example, see [Creating Ovens](creating-ovens.md). For creating and configuring an Oven from the CLI—`burnlist oven create`, `burnlist oven set`, `burnlist oven bind`, and `burnlist oven view`—see [Oven Authoring](oven-authoring.md). If a number can be typed by hand without doing the work, it is not evidence—measure the thing the work would have to produce. diff --git a/skills/burnlist/references/oven-authoring.md b/skills/burnlist/references/oven-authoring.md index a7f8092..198d839 100644 --- a/skills/burnlist/references/oven-authoring.md +++ b/skills/burnlist/references/oven-authoring.md @@ -18,7 +18,7 @@ The complete closed registries are: | Registry | Exact allowed values | | --- | --- | -| contracts | `checklist-progress@1`, `burnlist-differential-testing-data@1`, `burnlist-streaming-diff-data@2`, `burnlist-visual-parity-data@1` | +| contracts | `checklist-progress@1`, `burnlist-differential-testing-data@1`, `burnlist-model-lab-data@1`, `burnlist-streaming-diff-data@2`, `burnlist-visual-parity-data@1` | | themes | `checklist`, `differential-testing`, `streaming-diff`, `visual-parity` | | icons | `ClipboardList`, `Clock3`, `Gauge`, `TimerReset` | @@ -66,6 +66,8 @@ only inside a Git repository that does not ignore the path. ```sh burnlist oven list [--json] burnlist oven view [--json] +burnlist oven use [--repo ] [--force] +burnlist oven set [--repo ] burnlist oven bind [--repo ] burnlist oven unbind [--repo ] burnlist oven bindings [--repo ] @@ -87,6 +89,12 @@ burnlist oven fork `Checklist (checklist@0.1.0 · built-in)`, then reports `version`, `nodes`, `contract`, `theme`, `revision`, and `path`. `--json` includes `version` and `ovenRevision`. +- `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. +- `set` reads JSON from a file, stdin (`-`), or an inline JSON argument, + validates before mutation, and atomically publishes the canonical data file + plus binding. - `bind` records an Oven-to-data-file binding. - `unbind` removes an Oven-to-data-file binding. - `bindings` lists all recorded bindings. @@ -109,6 +117,47 @@ contain one. Creation scaffolds a starter `.oven` when omitted, rejects an existing id unless `--force` is given, and validates before writing. `--repo` 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: + +```sh +burnlist oven use differential-testing --repo . +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`. + +`set` resolves a repo's vendored Oven before the shipped or custom source. For a +built-in, it calls the same runtime validator used by that Oven's render handler; +there is no second schema-based approximation. A producer-managed Oven such as +Streaming Diff refuses a single JSON payload. A custom Oven with no registered +runtime validator checks that every `.oven` `source=` and `` +pointer resolves, then prints this explicit warning: + +```text +shape-only validation checks source pointers, not payload truth. +``` + +Shape is not truth: pointer presence does not prove types, freshness, +provenance, semantic correctness, or that adapter-computed evidence is honest. +Any JSON Schema shipped near an Oven is informational reference documentation, +not `set` authority and not package or pin content. + +Only after validation succeeds does `set` pretty-print the payload to the +gitignored canonical path `.local/burnlist/data/.json` and atomically update +`.local/burnlist/bindings.json`. A failure on a fresh install writes and binds +nothing. A rejected replacement or publication failure preserves the exact +prior data bytes and binding. Repeating an identical set is idempotent. + ## Vendoring and pinning an Oven `burnlist oven adopt ` copies the shipped source into the committed @@ -159,8 +208,13 @@ prints `Bound Oven to ` and `Store: /.local/burnlist/bindings.j The bound file is arbitrary JSON. Its shape must match the Oven's `source=` and `` RFC 6901 pointers. Generic checklist-theme Ovens do not -validate that payload at creation: pointers resolve when viewed, and missing -pointers render as empty or fallback values. +validate that payload at creation. A direct `bind` leaves pointers to resolve +when viewed, where missing values render empty or use fallbacks; `set` instead +rejects a missing declared pointer before changing canonical data or binding. + +Use `set` for a validated, private snapshot at the canonical path. Use `bind` +when a project-owned producer must keep updating its own file or directory in +place; `bind` records a path and does not copy or validate its current content. `burnlist oven view ` prints the compiled node tree plus a `node / prop / source` pointer table. It is for inspecting structure, never rendered data. @@ -195,7 +249,7 @@ cd # a git repo burnlist init # ignore .local/, register root # author kpi.oven (generic checklist-theme Oven) and instr.md, then: burnlist oven create deploy-status --instructions instr.md --oven kpi.oven -burnlist oven bind deploy-status deploy-data.json +burnlist oven set deploy-status deploy-data.json burnlist oven bindings # confirm the binding burnlist oven view deploy-status # structure only burnlist --scan-root # dashboard; open the "Custom Oven" row diff --git a/skills/burnlist/references/oven-contract.md b/skills/burnlist/references/oven-contract.md index 0193fa8..992af8a 100644 --- a/skills/burnlist/references/oven-contract.md +++ b/skills/burnlist/references/oven-contract.md @@ -13,7 +13,7 @@ An Oven does not: - import arbitrary UI components - start Codex -A project-specific adapter may produce normalized data, but that adapter is not part of the Oven. A `.oven` binding uses a JSON-pointer-like source beginning with `/`. Source-value shape is outside creation-time validation; the consuming adapter or renderer owns that check when data is available. +A project-specific adapter may produce normalized data, but that adapter is not part of the Oven. A `.oven` binding uses a JSON-pointer-like source beginning with `/`. Source-value shape is outside creation-time validation. At `oven set` time, a built-in uses its render handler's runtime validator; a custom Oven without one receives pointer-presence validation with an explicit `shape-only` warning. Shape validation never proves payload truth. ## Package @@ -31,7 +31,13 @@ An Oven directory is identified by a lowercase slug and contains these two canon An Oven's identity revision is `o1-sha256` over canonical JSON `{format:"burnlist-oven-content@2", id, instructions, oven}`. `detail.json` is retired from the data model and survives only in a read-only legacy path for old detail-based run snapshots. -Default Ovens ship with the skill. Custom Ovens are created under ignored `.local/burnlist/ovens/` state. The dashboard has no update endpoint, but the `burnlist oven` CLI can create, update, fork, list, view, bind, unbind, and show bindings under the same validation; built-in Ovens stay read-only there. Manual changes affect only future Runs. A built-in renderer may define and validate a versioned normalized-data contract; Differential Testing uses `burnlist-differential-testing-data@1`. For the controlled DSL vocabulary and source-binding conventions, see `creating-ovens.md`. +An optional `example/data.json` may sit beside a shipped Oven for `oven use`, +and a JSON Schema may sit beside implementation files as human/agent reference +documentation. Neither is canonical package content, neither is vendored, and +neither enters the identity revision or pin. JSON Schema is never the runtime +validation authority. + +Default Ovens ship with the skill. Custom Ovens are created under ignored `.local/burnlist/ovens/` state. The dashboard has no update endpoint, but the `burnlist oven` CLI can create, update, fork, list, view, use, set, bind, unbind, and show bindings; built-in Ovens stay read-only there. `set` publishes only to ignored `.local/burnlist/data/.json` after validation and preserves a prior valid install on failure. Manual source changes affect only future Runs. A built-in renderer may define and validate a versioned normalized-data contract; Differential Testing uses `burnlist-differential-testing-data@1`. For the controlled DSL vocabulary and source-binding conventions, see `creating-ovens.md`. ## Run Boundary diff --git a/src/cli/commands-help.test.mjs b/src/cli/commands-help.test.mjs index ace46f9..cffe5bc 100644 --- a/src/cli/commands-help.test.mjs +++ b/src/cli/commands-help.test.mjs @@ -37,6 +37,23 @@ test("install, uninstall, and hooks subcommand help exit successfully with usage } finally { context.cleanup(); } }); +test("top-level and Oven help expose the validated use and set flow", () => { + const context = fixture({ git: false }); + try { + 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); + + const oven = run(context.directory, ["oven", "help"]); + assert.equal(oven.status, 0, oven.stderr); + assert.match(oven.stdout, /burnlist oven use \[--repo \] \[--force\]/u); + assert.match(oven.stdout, /burnlist oven set \[--repo \]/u); + assert.match(oven.stdout, /same runtime validator/u); + assert.match(oven.stdout, /shape-only/u); + assert.match(oven.stdout, /\.local\/burnlist\/data\/\.json/u); + } finally { context.cleanup(); } +}); + test("empty skill and hook uninstalls report that there is nothing to remove", () => { const context = fixture(); try { diff --git a/website/scripts/skill-content.mjs b/website/scripts/skill-content.mjs index 4de25c1..de66075 100644 --- a/website/scripts/skill-content.mjs +++ b/website/scripts/skill-content.mjs @@ -2,7 +2,7 @@ * Builds the content of skill.md — a single, self-contained Burnlist skill * document served at the site root (https://burnlist.dev/skill.md) so an * agent pointed at that URL has everything it needs: what Burnlist is, how - * to install it, the full CLI surface, the lifecycle, the five ovens, and a + * to install it, the full CLI surface, the lifecycle, the six ovens, and a * listing of every doc page. Kept accurate to the real CLI help output and * the docs it is generated alongside; do not describe behavior the CLI does * not have (no loop feature, no componentization, no work execution). @@ -24,7 +24,7 @@ Usage: burnlist differential-testing sdk burnlist streaming-diff ... burnlist hooks [install|uninstall|status] [--agent codex,claude] [--untracked] (bare defaults to status) - burnlist oven ... + burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] burnlist ready [--repo ] @@ -59,7 +59,7 @@ export function buildSkillMarkdown({ documents, siteUrl, cliHelp = FALLBACK_CLI_ return `# Burnlist skill -> Point an agent at ${siteUrl}/skill.md for a complete, self-contained Burnlist skill: what it is, how to install it, its full CLI surface, its lifecycle, and its five ovens. +> Point an agent at ${siteUrl}/skill.md for a complete, self-contained Burnlist skill: what it is, how to install it, its full CLI surface, its lifecycle, and its six ovens. ## What Burnlist is @@ -156,7 +156,7 @@ await writeFile(new URL('migration-status-data.json', out), JSON.stringify({ An Oven is only as honest as its adapter; an unwired number is worse than no number. When a signal is not wired to reality, the adapter reports \`null\` or \`"wired": false\`, never a fabricated number, and that unwired signal becomes the next Burnlist item. -The project owns and runs the adapter after each batch, on a schedule, or in CI—Burnlist never runs it, and \`burnlist oven bind\` only records the path. +The project owns and runs the adapter after each batch, on a schedule, or in CI—Burnlist never runs it. Use \`burnlist oven set \` for a validated canonical snapshot; use \`burnlist oven bind\` only when a producer must keep updating its own path in place. ### Point a Burnlist item at an Oven number @@ -186,17 +186,20 @@ A Burnlist's state is its location. The whole folder moves through four lifecycl Folders: \`notes/burnlists/{draft,ready,inprogress,completed}//\`. \`goal.md\` is the stable contract (Goal, Guardrails, Proof Authority, Ordering Intent, Stop Conditions, Handoff); \`burnlist.md\` is the hot, shrinking task state (an ordered Active Checklist and a terse Completed ledger); \`completed.md\` is optional durable per-burn history for humans. Run \`burnlist --plan --check\` to validate and \`burnlist --stamp\` to generate the mechanical timestamp used in a completion ledger line. -## The five ovens +## The six ovens -An Oven is a named, declarative, non-executable recipe for a Burn — data, never code. Five built-in, read-only ovens ship with Burnlist: +An Oven is a named, declarative, non-executable recipe for a Burn — data, never code. Six built-in, read-only ovens ship with Burnlist: - **Checklist** tracks the active work queue and progress. - **Differential Testing** provides aligned reference-versus-candidate series, optional aggregate telemetry, and exact-first evidence. +- **Model Lab** inspects one prepared model and its topology, publication, and provenance evidence. - **Streaming Diff** surfaces recently published, session-scoped pre-to-post diff cards read from a local feed. - **Performance Tracing** renders retained browser-output timing evidence — frame pacing, budget checks, and slow steps — from a project-owned trace run. - **Visual Parity** compares trusted reference and candidate frames as isolated render passes, gating each render domain on calibrated channel, mean-delta, and changed-pixel bounds. -Author and inspect ovens with \`burnlist oven \`. Custom ovens live in ignored, repo-scoped \`.local/burnlist/ovens/\` state. +Author and inspect ovens with \`burnlist oven \`. Custom ovens live in ignored, repo-scoped \`.local/burnlist/ovens/\` state. + +\`burnlist oven use \` adopts a shipped Oven and installs data only when an exact shipped \`example/data.json\` already exists and passes validation. Otherwise it adopts only, creates no data or binding, and prints the exact \`oven set\` next step; it never generates a payload from a schema, fixture, or instructions. \`burnlist oven set \` validates before mutation and atomically publishes \`.local/burnlist/data/.json\` plus its binding. Built-ins use the same runtime validator as their render handler. Custom Ovens without one check declared pointers and print a \`shape-only\` warning: shape is not truth. A rejected fresh set writes nothing; a rejected replacement preserves the exact prior data and binding. JSON Schemas are informational references only and never enter the Oven package, revision, or pin. Ovens carry an \`id@version\` identity, distinct from the content revision. Vendoring a shipped Oven with \`burnlist oven adopt \` copies and pins it in committed \`.burnlist/ovens//\`; \`burnlist oven upgrade \` is the opt-in way to move that pin. Dashboard paths are \`/r//\` for a Burnlist, \`/r///o/\` for its Oven lens, \`/r//o/\` for a repo-scoped Oven, \`/ovens\` for the catalog, and \`/ovens/\` for the explainer. diff --git a/website/src/content/docs/cli.mdx b/website/src/content/docs/cli.mdx index 831c303..59245e7 100644 --- a/website/src/content/docs/cli.mdx +++ b/website/src/content/docs/cli.mdx @@ -51,10 +51,27 @@ burnlist init [path] [--track] ## Ovens ```sh -burnlist oven ... +burnlist oven ... ``` -Author and inspect Ovens. Ovens carry an `id@version` identity and can be vendored and pinned per project with `adopt` (committed under `.burnlist/ovens//`) and opt-in `upgrade`. (`burnlist oven help` lists a few more subcommands, such as `fork`.) See [Ovens](/ovens). +Author and inspect Ovens. Ovens carry an `id@version` identity and can be vendored and pinned per project with `adopt` (committed under `.burnlist/ovens//`) and opt-in `upgrade`. + +```sh +burnlist oven use [--repo ] [--force] +burnlist oven set [--repo ] +``` + +`use` adopts a shipped Oven. It validates and binds only an existing shipped +`example/data.json`; without that exact file it adopts only, creates no data or +binding, and prints the `oven set` next step. `set` accepts a file, stdin, or +inline JSON. It validates first with the built-in render handler's runtime +validator, then atomically writes `.local/burnlist/data/.json` and its +binding. A custom Oven without a runtime validator checks all declared source +pointers and prints a `shape-only` warning: pointer presence does not prove +types, freshness, provenance, or truth. Rejection writes nothing on a fresh +install and preserves the exact prior data and binding on an existing one. +JSON Schema is informational documentation, never `set` authority or package +identity. See [Create your own Oven](/ovens/authoring). ## Skills diff --git a/website/src/content/docs/getting-started.mdx b/website/src/content/docs/getting-started.mdx index 45a02c7..8d76f4d 100644 --- a/website/src/content/docs/getting-started.mdx +++ b/website/src/content/docs/getting-started.mdx @@ -36,4 +36,4 @@ You can also launch the dashboard server with `burnlist`, create a workspace wit ## Coming next -Upcoming sections cover the [CLI](/cli), [Ovens](/ovens), and the [Dashboard](/dashboard). Burnlist also includes `burnlist oven `, `burnlist differential-testing ...`, and `burnlist streaming-diff ...` commands. +Upcoming sections cover the [CLI](/cli), [Ovens](/ovens), and the [Dashboard](/dashboard). Burnlist also includes `burnlist oven `, `burnlist differential-testing ...`, and `burnlist streaming-diff ...` commands. diff --git a/website/src/content/docs/ovens.mdx b/website/src/content/docs/ovens.mdx index 0205192..424c76c 100644 --- a/website/src/content/docs/ovens.mdx +++ b/website/src/content/docs/ovens.mdx @@ -21,10 +21,11 @@ An Oven cannot execute commands or code, collect or transform project data, own ## Built-in Ovens -Five built-in, read-only Ovens ship with Burnlist: +Six built-in, read-only Ovens ship with Burnlist: - Checklist tracks the active work queue and progress. - Differential Testing provides aligned reference-versus-candidate series, optional aggregate telemetry, and exact-first evidence. +- Model Lab inspects one prepared model and its topology, publication, and provenance evidence. - Streaming Diff surfaces recently published, session-scoped pre-to-post diff cards read from a local feed. - Performance Tracing renders retained browser-output timing evidence — frame pacing, budget checks, and slow steps — from a project-owned trace run. - Visual Parity compares trusted reference and candidate frames as isolated render passes, gating each render domain on calibrated channel, mean-delta, and changed-pixel bounds. @@ -41,6 +42,8 @@ burnlist oven create --package # JSON: {name?, instructions, ove burnlist oven create --instructions [--oven ] [--name ] burnlist oven update [same inputs as create] burnlist oven fork +burnlist oven use [--repo ] [--force] +burnlist oven set [--repo ] burnlist oven bind [--repo ] # bind an Oven to a normalized data payload burnlist oven unbind [--repo ] burnlist oven bindings [--repo ] # list this repo's Oven data bindings @@ -48,15 +51,22 @@ burnlist oven bindings [--repo ] # list this repo's Oven data bin `view` derives structure from the compiled IR. Any file input accepts `-` for standard input. `--name` owns the level-one heading. `create` scaffolds a starter `.oven` when one is omitted, and refuses an existing id; use `update`, or `--force`. Built-in Ovens are read-only, so fork one to customize it. -Validation reuses the same `oven-contract` used by the dashboard. Invalid source or a missing H1 is rejected before anything is written. This CLI never executes Oven instructions. +Source validation reuses the same `oven-contract` used by the dashboard. +Invalid source or a missing H1 is rejected before anything is written. For +data, `use` installs only an existing validated `example/data.json`, while +`set` calls the same built-in runtime validator as the render handler before +atomically publishing `.local/burnlist/data/.json` and its binding. A +custom Oven without a runtime validator receives `shape-only` pointer +validation, which does not prove truth. This CLI never executes Oven +instructions. Structure, widgets, and formats are the `.oven` DSL vocabulary. See the [.oven DSL reference](/ovens/dsl-reference) and [The .oven system](/ovens/oven-system). ## Binding -A `.oven` source binding is a JSON-pointer into a single read-only data document produced by a project-specific adapter at view time. The adapter is not part of the Oven, and source-value shape is not validated at creation time: the renderer and adapter are the final authority. Record the expected document shape in `instructions.md`, for example in a `## State Contract` section. +A `.oven` source binding is a JSON-pointer into a single read-only data document produced by a project-specific adapter at view time. The adapter is not part of the Oven, and source-value shape is not validated at creation time. At `set` time, built-ins use their runtime validators and custom Ovens without one require every source pointer to resolve. That fallback is shape-only: the project adapter remains responsible for freshness, provenance, semantics, and truth. Record the expected document shape in `instructions.md`, for example in a `## Data Shape` section. -Rich built-in Ovens define a versioned normalized-data contract validated in code. For example, Differential Testing uses `burnlist-differential-testing-data@1`, bound with `burnlist --oven-data differential-testing=`. +Rich built-in Ovens define a versioned normalized-data contract validated in code. For example, Differential Testing uses `burnlist-differential-testing-data@1`, configured with `burnlist oven set differential-testing `. Any JSON Schema is informational reference documentation, not runtime validation authority or Oven package identity. ## Runs diff --git a/website/src/content/docs/ovens/authoring.mdx b/website/src/content/docs/ovens/authoring.mdx index 9578d1c..b0c395d 100644 --- a/website/src/content/docs/ovens/authoring.mdx +++ b/website/src/content/docs/ovens/authoring.mdx @@ -20,7 +20,7 @@ burnlist oven: Oven .oven source is invalid: Unknown contract | Registry | Exact allowed values | | --- | --- | -| Contracts | `checklist-progress@1`, `burnlist-differential-testing-data@1`, `burnlist-streaming-diff-data@2`, `burnlist-visual-parity-data@1` | +| Contracts | `checklist-progress@1`, `burnlist-differential-testing-data@1`, `burnlist-model-lab-data@1`, `burnlist-streaming-diff-data@2`, `burnlist-visual-parity-data@1` | | Themes | `checklist`, `differential-testing`, `streaming-diff`, `visual-parity` | | Icons | `ClipboardList`, `Clock3`, `Gauge`, `TimerReset` | @@ -85,8 +85,9 @@ matches the Oven's `source=` and `` [RFC 6901 JSON pointers](https Here, `healthyPct` renders as `"96.00%"`; `deployedAt` renders as a compact age such as `"3h"`. Generic checklist-theme Ovens do not validate payload shape at -creation. Pointers resolve when viewed, and missing pointers render as empty or -fallback values. +creation. A direct `bind` leaves pointers to resolve when viewed, where missing +values render empty or use fallbacks; `set` instead rejects a missing declared +pointer before changing the canonical data or binding. The small Streaming Diff illustration uses its matching built-in pair: @@ -124,6 +125,7 @@ bindings. Bind an Oven to its JSON payload, inspect bindings, remove one, or copy an Oven: ```sh +burnlist oven set [--repo ] burnlist oven bind [--repo ] burnlist oven bindings [--repo ] burnlist oven unbind [--repo ] @@ -151,6 +153,47 @@ Successful binding prints `Bound Oven to ` and `No binding exists…`. `fork` copies a built-in or custom Oven to a new custom id and records its `forkedFrom` provenance; built-in Ovens cannot be updated. +### Validated data setup + +Use a shipped Oven in a repository, then provide its payload when it has no +starter: + +```sh +burnlist oven use differential-testing --repo . +burnlist oven set differential-testing ./differential-testing.json --repo . +``` + +`use` keeps `adopt` and pin semantics. It looks only for a shipped, +non-executable `ovens//example/data.json`. When present, that exact payload +is validated and the Oven, data, and binding are installed transactionally. +When absent, the Oven is adopted with no data or binding and the CLI prints the +exact `oven set` next step. Reference/candidate inputs, test fixtures, schemas, +and instructions never become starter data. The optional example is not +vendored and does not enter the Oven revision or pin. + +`set` accepts a JSON file, stdin (`-`), or inline JSON. A built-in uses the same +runtime validator as its render handler; no JSON Schema validator substitutes +for it. Producer-managed Streaming Diff refuses a single JSON payload. A custom +Oven without a registered runtime validator requires every `source=` and +`` pointer to resolve and warns: + +```text +shape-only validation checks source pointers, not payload truth. +``` + +Pointer presence cannot establish types, freshness, provenance, semantic +correctness, or honest evidence. Any JSON Schema shipped near an Oven is +informational reference documentation only, not validation authority or +package/pin content. + +After validation, `set` atomically writes the gitignored canonical file +`.local/burnlist/data/.json` and its binding. Rejection on a fresh install +writes nothing; rejection or publication failure on an existing install keeps +the exact prior data bytes and binding. Identical sets are idempotent. Use +`bind` instead when a project-owned producer must update its own file or +directory in place: `bind` records a path without copying or validating its +current content. + Shipped and custom Ovens can be listed, and an Oven can be inspected by its id: ```sh @@ -256,7 +299,7 @@ this sequence creates and displays the example: cd # a git repo burnlist init # ignore .local/, register root burnlist oven create deploy-status --instructions instr.md --oven kpi.oven -burnlist oven bind deploy-status deploy-data.json +burnlist oven set deploy-status deploy-data.json burnlist oven bindings # confirm the binding burnlist oven view deploy-status # structure only burnlist --scan-root # dashboard; open the "Custom Oven" row diff --git a/website/src/content/docs/ovens/designing-ovens.mdx b/website/src/content/docs/ovens/designing-ovens.mdx index 3851cbb..b918f62 100644 --- a/website/src/content/docs/ovens/designing-ovens.mdx +++ b/website/src/content/docs/ovens/designing-ovens.mdx @@ -73,11 +73,11 @@ await writeFile(new URL('migration-status-data.json', out), JSON.stringify({ }, null, 2) + '\n'); ``` -Bind it with `burnlist oven bind migration-status .local/burnlist/migration-status-data.json`; the CLI is in [authoring](/ovens/authoring). +Validate and snapshot it with `burnlist oven set migration-status .local/burnlist/migration-status-data.json`; the CLI is in [authoring](/ovens/authoring). A custom Oven gets pointer-presence validation and a `shape-only` warning: that proves neither types nor truth. Use `burnlist oven bind` instead only when a producer must keep updating its own path in place; binding does not copy or validate current content. **An unwired number is worse than no number.** `legacyReadGreen` is `null` because no adapter reads the legacy path yet: an Oven is only as honest as its adapter, and a real gap that reads as a fabricated pass makes the agent trust a lie. Report `null` (or `"wired": false`), never a guess, and make wiring that signal the next Burnlist item. -**The project owns and runs the adapter—Burnlist never does.** `burnlist oven bind` only records where the JSON lives; the dashboard only reads it. Re-run the adapter after each batch, on a schedule, or in CI—a stale adapter is a stale Oven. +**The project owns and runs the adapter—Burnlist never does.** `burnlist oven set` validates and snapshots supplied JSON, `burnlist oven bind` only records where producer-managed data lives, and the dashboard only reads it. Re-run the adapter and `set` after each batch, or bind a producer-owned path refreshed on a schedule or in CI—a stale adapter is a stale Oven. ## Point a Burnlist item at an Oven number diff --git a/website/src/content/docs/ovens/differential-testing.mdx b/website/src/content/docs/ovens/differential-testing.mdx index 48bf8d7..032e6e1 100644 --- a/website/src/content/docs/ovens/differential-testing.mdx +++ b/website/src/content/docs/ovens/differential-testing.mdx @@ -69,10 +69,18 @@ burnlist differential-testing schema burnlist differential-testing validate burnlist differential-testing validate-bundle burnlist differential-testing sdk -burnlist --oven-data differential-testing= +burnlist oven use differential-testing --repo . +burnlist oven set differential-testing --repo . ``` -Small comparisons may bind the data document directly. Large comparisons use the `burnlist-differential-testing-bundle@1` transport so Burnlist validates field records sequentially and range-reads only the visible page. `burnlist differential-testing sdk` prints the packaged worker module path. +`use` adopts without data because the shipped reference and candidate examples +are adapter inputs, not `example/data.json`. `set` validates a single document +with `validateDifferentialTestingRuntimeData`, the same authority used by the +render handler, before publishing canonical data. The JSON Schema printed by +`schema` is informational reference documentation only; it is not `set` +authority and does not enter the Oven package, revision, or pin. + +Small comparisons may use the single data document. Large comparisons use the `burnlist-differential-testing-bundle@1` transport so Burnlist validates field records sequentially and range-reads only the visible page. `burnlist differential-testing sdk` prints the packaged worker module path. ## Rendering diff --git a/website/src/content/docs/ovens/dsl-reference.mdx b/website/src/content/docs/ovens/dsl-reference.mdx index 489ad0f..42eb74f 100644 --- a/website/src/content/docs/ovens/dsl-reference.mdx +++ b/website/src/content/docs/ovens/dsl-reference.mdx @@ -188,7 +188,7 @@ unless the chosen theme styles the token. | Registry | Allowed values | | --- | --- | | Themes | `checklist`, `differential-testing`, `streaming-diff`, `visual-parity` | -| Contracts | `checklist-progress@1`, `burnlist-differential-testing-data@1`, `burnlist-streaming-diff-data@2`, `burnlist-visual-parity-data@1` | +| Contracts | `checklist-progress@1`, `burnlist-differential-testing-data@1`, `burnlist-model-lab-data@1`, `burnlist-streaming-diff-data@2`, `burnlist-visual-parity-data@1` | | Icons | `ClipboardList`, `Clock3`, `Gauge`, `TimerReset` | These registries are a closed allowlist: a custom Oven must reuse one of the diff --git a/website/src/content/docs/ovens/oven-system.mdx b/website/src/content/docs/ovens/oven-system.mdx index 1a71ba8..843d700 100644 --- a/website/src/content/docs/ovens/oven-system.mdx +++ b/website/src/content/docs/ovens/oven-system.mdx @@ -19,6 +19,12 @@ This is the complete committed `streaming-diff.oven`. It describes what to rende The `version` is the Oven's semver `id@version` identity, distinct from its content revision. A shipped Oven can be pinned per project with `burnlist oven adopt ` under committed `.burnlist/ovens//`; use opt-in `burnlist oven upgrade ` to move that pin. +`burnlist oven use ` composes adoption with an optional shipped +`example/data.json`. The example is validated and bound only when that exact +file exists; otherwise `use` adopts only. Examples and informational JSON +Schemas are outside the canonical two-file package, vendored copy, revision, +and pin. JSON Schema never replaces the runtime validator used by `oven set`. + ## Rendering pipeline ```text @@ -40,7 +46,7 @@ only when it needs a custom data source: / instructions.md # outcome, state, inputs, and evidence rules .oven # declarative detail-page source - contract.mjs / handler.mjs / adapter / data.schema.json # optional, flat data layer + contract.mjs / handler.mjs / adapter / data.schema.json # optional implementation/reference files; not package identity ``` The oven-ir Vite plugin generates IR from `.oven`; authors never create or commit `.ir.json`. Checklist has no data layer. Performance Tracing and `visual-parity` have flat `contract.mjs` and `handler.mjs` files; Differential Testing has a flat data layer of `contract.mjs`, `handler.mjs`, `data-contract.mjs`, `adapter-sdk.mjs`, `transport.mjs`, `worker-runtime.mjs`, and `data.schema.json`. Streaming Diff is the one Oven that keeps a large `engine/` directory and `e2e/`. diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 94a59b8..d0ace39 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -217,7 +217,7 @@ const skillUrl = new URL('/skill.md', Astro.site).toString();

CLI

One command, burnlist, launches the loopback dashboard. Lifecycle verbs (new, ready, start, burn, close) plus protocol helpers (--check, --digest, --stamp) drive the burndown. Run burnlist --help for the full surface.

Dashboard

A read-only web observer. It scans the notes/burnlists/ lifecycle folders across your registered repos and refreshes automatically. It never mutates canonical task state.

-

Ovens

Declarative, non-executable recipes for a Burn: two files — instructions.md (outcome + evidence rules) and <id>.oven (declarative detail-page source). Five built-in Ovens ship: Checklist, Differential Testing, Streaming Diff, Performance Tracing, and Visual Parity.

+

Ovens

Declarative, non-executable recipes for a Burn: two files — instructions.md (outcome + evidence rules) and <id>.oven (declarative detail-page source). Six built-in Ovens ship: Checklist, Differential Testing, Model Lab, Streaming Diff, Performance Tracing, and Visual Parity.

Skills

A single agent skill owns the full Burnlist lifecycle — creation, hardening, execution, and maintenance — installable for both Claude and Codex, per-repo or global.

Hooks

burnlist hooks install --agent codex,claude merges Streaming Diff commands into .codex/hooks.json and .claude/settings.json, preserving your existing hook entries.

From 1f812265e5b71bd9428a7848ca602868c261927a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 22 Jul 2026 01:35:28 +0200 Subject: [PATCH 16/21] test: prove complete oven data flows (260721-002) --- scripts/verify-test-files.mjs | 3 + src/cli/oven-data-flow-e2e.test.mjs | 183 ++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/cli/oven-data-flow-e2e.test.mjs diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 17fe4a2..c20707b 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -56,6 +56,9 @@ export const verificationTestFiles = [ "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", + "src/cli/oven-use.test.mjs", "src/cli/oven-fork-id.test.mjs", "src/cli/oven-cli-stdout.test.mjs", "src/cli/oven-storage.test.mjs", diff --git a/src/cli/oven-data-flow-e2e.test.mjs b/src/cli/oven-data-flow-e2e.test.mjs new file mode 100644 index 0000000..3a83483 --- /dev/null +++ b/src/cli/oven-data-flow-e2e.test.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; +import { buildPayload } from "../../ovens/differential-testing/example/adapter.mjs"; +import { checklistFixture } from "../../dashboard/src/components/ChecklistDashboard/ChecklistDashboard.fixture.mjs"; +import { compileOven } from "../ovens/dsl/oven-compile.mjs"; +import { readBindingStore } from "../server/oven-bindings.mjs"; +import { canonicalOvenDataPath } from "../server/oven-data-store.mjs"; +import { vendoredOvenPath } from "../server/oven-vendor.mjs"; +import { useShippedOven } from "./oven-use.mjs"; + +const packageRoot = resolve(new URL("../..", import.meta.url).pathname); +const binPath = join(packageRoot, "bin", "burnlist.mjs"); + +function fixture(t, name) { + const root = mkdtempSync(join(tmpdir(), `burnlist-data-flow-${name}-`)); + const repo = join(root, "repo"); + const home = join(root, "home"); + mkdirSync(repo); + mkdirSync(home); + execFileSync("git", ["init", "-q"], { cwd: repo }); + const context = { root, repo, env: { ...process.env, HOME: home } }; + const initialized = runResult(context, "init", repo); + assert.equal(initialized.status, 0, initialized.stderr || initialized.stdout); + assert.match(initialized.stdout, /Initialized 4 lifecycle folders/u); + assert.match(readFileSync(join(repo, ".git", "info", "exclude"), "utf8"), /\/\.local\//u); + t.after(() => rmSync(root, { recursive: true, force: true })); + return context; +} + +function runResult(context, ...args) { + return spawnSync(process.execPath, [binPath, ...args], { + cwd: context.repo, + env: context.env, + encoding: "utf8", + }); +} + +function run(context, ...args) { + const result = runResult(context, ...args); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result; +} + +function differentialPayload() { + const example = join(packageRoot, "ovens", "differential-testing", "example"); + return buildPayload( + JSON.parse(readFileSync(join(example, "reference.json"), "utf8")), + JSON.parse(readFileSync(join(example, "candidate.json"), "utf8")), + ); +} + +function runtimeInvalidPayload(valid) { + const invalid = JSON.parse(JSON.stringify(valid)); + invalid.summary.runs.total = 1; + return invalid; +} + +async function renderDifferentialTesting(vendoredSource, payload) { + const output = mkdtempSync(join(packageRoot, ".oven-data-flow-e2e-")); + try { + const runtimeOutput = join(output, "OvenRuntime.mjs"); + const adapterOutput = join(output, "differential-testing-adapter.mjs"); + const sourceDir = join(packageRoot, "dashboard", "src"); + await Promise.all([ + build({ entryPoints: [join(sourceDir, "oven", "runtime", "OvenRuntime.tsx")], bundle: true, format: "esm", outfile: runtimeOutput, platform: "node", alias: { "@": sourceDir, "@lib": join(sourceDir, "lib"), "@oven": join(sourceDir, "oven") }, jsx: "automatic", packages: "external", target: "node18" }), + build({ entryPoints: [join(sourceDir, "lib", "differential-testing-adapter.ts")], bundle: true, format: "esm", outfile: adapterOutput, platform: "node", target: "node18" }), + ]); + const cacheKey = `?test=${Date.now()}`; + const [{ OvenRuntime }, { adaptDifferentialTesting }] = await Promise.all([ + import(`${pathToFileURL(runtimeOutput).href}${cacheKey}`), + import(`${pathToFileURL(adapterOutput).href}${cacheKey}`), + ]); + const compiled = compileOven(vendoredSource, { file: "vendored-differential-testing.oven" }); + assert.equal(compiled.ok, true, compiled.ok ? "" : JSON.stringify(compiled.diagnostics)); + return renderToStaticMarkup(createElement(OvenRuntime, { + ir: compiled.ir, + payload: adaptDifferentialTesting(payload), + })); + } finally { + rmSync(output, { recursive: true, force: true }); + } +} + +function createCustomOven(context) { + const instructions = join(context.repo, "custom-shape.md"); + const source = join(context.repo, "custom-shape.oven"); + writeFileSync(instructions, "# Custom Shape\n\nPointer-only validation fixture.\n"); + writeFileSync(source, ` + + +`); + run(context, "oven", "create", "custom-shape", "--instructions", instructions, "--oven", source, "--repo", context.repo); +} + +function shippedChecklist() { + const root = join(packageRoot, "ovens", "checklist"); + return { + id: "checklist", + builtIn: true, + instructions: readFileSync(join(root, "instructions.md"), "utf8"), + oven: readFileSync(join(root, "checklist.oven"), "utf8"), + }; +} + +test("initialized repositories enforce the complete validated Oven data flow", async (t) => { + const context = fixture(t, "existing"); + const valid = differentialPayload(); + const invalid = runtimeInvalidPayload(valid); + const validPath = join(context.repo, "valid.json"); + const invalidPath = join(context.repo, "invalid.json"); + writeFileSync(validPath, JSON.stringify(valid)); + writeFileSync(invalidPath, JSON.stringify(invalid)); + + const used = run(context, "oven", "use", "differential-testing", "--repo", context.repo); + const vendored = vendoredOvenPath(context.repo, "differential-testing"); + assert.match(used.stdout, /adopted without data/u); + assert.deepEqual(readdirSync(vendored).sort(), ["differential-testing.oven", "instructions.md", "pin.json"]); + assert.equal(existsSync(canonicalOvenDataPath(context.repo, "differential-testing")), false); + assert.equal(Object.hasOwn(readBindingStore(context.repo).bindings, "differential-testing"), false); + + run(context, "oven", "set", "differential-testing", validPath, "--repo", context.repo); + const dataPath = canonicalOvenDataPath(context.repo, "differential-testing"); + const bindingPath = join(context.repo, ".local", "burnlist", "bindings.json"); + assert.deepEqual(JSON.parse(readFileSync(dataPath, "utf8")), valid); + assert.equal(readBindingStore(context.repo).bindings["differential-testing"].path, ".local/burnlist/data/differential-testing.json"); + const markup = await renderDifferentialTesting( + readFileSync(join(vendored, "differential-testing.oven"), "utf8"), + JSON.parse(readFileSync(dataPath, "utf8")), + ); + assert.match(markup, /No Differential Testing scenarios/u); + + const priorData = readFileSync(dataPath); + const priorBinding = readFileSync(bindingPath); + const rejected = runResult(context, "oven", "set", "differential-testing", invalidPath, "--repo", context.repo); + assert.notEqual(rejected.status, 0); + assert.match(rejected.stderr, /total must equal passed \+ failed \+ blocked/u); + assert.deepEqual(readFileSync(dataPath), priorData); + assert.deepEqual(readFileSync(bindingPath), priorBinding); + + createCustomOven(context); + const customAccepted = runResult(context, "oven", "set", "custom-shape", '{"summary":{"value":42}}', "--repo", context.repo); + assert.equal(customAccepted.status, 0, customAccepted.stderr); + assert.match(`${customAccepted.stdout}${customAccepted.stderr}`, /shape-only validation checks source pointers, not payload truth/u); + const customPath = canonicalOvenDataPath(context.repo, "custom-shape"); + const priorCustom = readFileSync(customPath); + const priorStore = readFileSync(bindingPath); + const customRejected = runResult(context, "oven", "set", "custom-shape", '{"summary":{}}', "--repo", context.repo); + assert.notEqual(customRejected.status, 0); + assert.match(customRejected.stderr, /\/summary\/value.*does not resolve/u); + assert.deepEqual(readFileSync(customPath), priorCustom); + assert.deepEqual(readFileSync(bindingPath), priorStore); + + const builtIns = join(context.root, "built-ins"); + mkdirSync(join(builtIns, "checklist", "example"), { recursive: true }); + writeFileSync(join(builtIns, "checklist", "example", "data.json"), JSON.stringify(checklistFixture)); + const exampleUse = useShippedOven({ + id: "checklist", + repoRoot: context.repo, + builtInOvensDir: builtIns, + readOvenDir: () => shippedChecklist(), + now: () => new Date("2026-07-22T00:00:00.000Z"), + }); + assert.match(exampleUse.output, /Set shipped example data/u); + assert.deepEqual(JSON.parse(readFileSync(canonicalOvenDataPath(context.repo, "checklist"), "utf8")), checklistFixture); + assert.equal(readBindingStore(context.repo).bindings.checklist.path, ".local/burnlist/data/checklist.json"); + + const fresh = fixture(t, "fresh"); + const freshInvalid = join(fresh.repo, "invalid.json"); + writeFileSync(freshInvalid, JSON.stringify(invalid)); + const freshRejected = runResult(fresh, "oven", "set", "differential-testing", freshInvalid, "--repo", fresh.repo); + assert.notEqual(freshRejected.status, 0); + assert.match(freshRejected.stderr, /data validation failed/u); + assert.equal(existsSync(canonicalOvenDataPath(fresh.repo, "differential-testing")), false); + assert.equal(existsSync(join(fresh.repo, ".local", "burnlist", "bindings.json")), false); +}); From 419015d48358863ac34f982c435da0aa34d814a1 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 22 Jul 2026 03:36:17 +0200 Subject: [PATCH 17/21] fix: render repository-pinned ovens in live pages (260721-002) --- dashboard/src/App.tsx | 10 +- .../ChecklistDashboard/ChecklistOvenView.tsx | 6 +- .../CustomOvenView/CustomOvenView.tsx | 10 +- .../custom-oven-render.test.mjs | 32 +- .../DifferentialTestingOven.test.mjs | 25 +- .../DifferentialTestingOven.tsx | 11 +- .../src/components/ModelLab/ModelLab.test.mjs | 125 +++++++ .../src/components/ModelLab/ModelLab.tsx | 351 ++---------------- .../OvenDefinition/OvenDefinition.tsx | 15 + .../src/components/OvenDefinition/index.ts | 1 + .../StreamingDiff/StreamingDiff.tsx | 11 +- .../streaming-diff-dom-golden.test.mjs | 4 + .../components/VisualParity/VisualParity.tsx | 7 +- dashboard/src/components/index.ts | 1 + dashboard/src/hooks/index.ts | 2 + dashboard/src/hooks/useOvenDefinition.ts | 30 ++ dashboard/src/lib/index.ts | 1 + dashboard/src/lib/oven-definition.mjs | 27 ++ dashboard/src/lib/oven-definition.test.mjs | 69 ++++ .../src/oven/ModelLabView/ModelLabView.tsx | 310 ++++++++++++++++ dashboard/src/oven/ModelLabView/index.ts | 2 + dashboard/src/oven/runtime/OvenNode.tsx | 2 + dashboard/src/oven/runtime/OvenRuntime.tsx | 16 +- .../oven-runtime-controlled-payload.test.ts | 28 ++ ovens/model-lab/model-lab.oven | 2 +- scripts/verify-test-files.mjs | 2 + src/ovens/dsl/oven-grammar.mjs | 3 +- src/ovens/dsl/oven-validate.mjs | 2 +- 28 files changed, 723 insertions(+), 382 deletions(-) create mode 100644 dashboard/src/components/ModelLab/ModelLab.test.mjs create mode 100644 dashboard/src/components/OvenDefinition/OvenDefinition.tsx create mode 100644 dashboard/src/components/OvenDefinition/index.ts create mode 100644 dashboard/src/hooks/useOvenDefinition.ts create mode 100644 dashboard/src/lib/oven-definition.mjs create mode 100644 dashboard/src/lib/oven-definition.test.mjs create mode 100644 dashboard/src/oven/ModelLabView/ModelLabView.tsx create mode 100644 dashboard/src/oven/ModelLabView/index.ts create mode 100644 dashboard/src/oven/runtime/oven-runtime-controlled-payload.test.ts diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 29b3280..f01d4ec 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,17 +1,19 @@ import { useMemo, useState } from "react"; import { ListChecks } from "lucide-react"; -import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, OvenCatalog, OvenExplainer, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; +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"; -import { currentSection, filterFromUrl, selectedBurnlist } from "@lib"; +import { checklistOvenRepoKey, currentSection, filterFromUrl, ovenRepoKey, selectedBurnlist } from "@lib"; import type { Filter } from "@lib"; import { Button } from "@layout"; export function App() { const section = currentSection(); const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search]); + const repoKey = ovenRepoKey(); const [filter, setFilter] = useState(() => filterFromUrl(FILTERS)); const dashboardSection = ["landing", "burnlist", "streaming-diff"].includes(section) ? "burnlists" : section; const { projects, progress, error, loading } = useDashboardData({ section: dashboardSection, selected }); + const checklistRepoKey = checklistOvenRepoKey(progress, selected); const visibleBurnlistCount = projects.reduce((total, project) => total + project.entries.filter((entry) => filter === "all" || entry.status === filter).length, 0); const visibleProjectCount = projects.filter((project) => project.entries.some((entry) => filter === "all" || entry.status === filter)).length; @@ -30,9 +32,9 @@ export function App() {
- {section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ? : section === "oven-explainer" ? : selected ? ( + {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 ? ( error ? : loading && !progress ? : progress ? ( - <> + <>{(ir) => } ) : ) : (
diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx index 156ccb7..65e8dfb 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx @@ -1,15 +1,15 @@ import { useEffect, useMemo } from "react"; +import type { ResolvedOvenIr } from "@hooks"; import type { ChecklistProgressData } from "@lib"; import { adaptChecklist } from "@lib/checklist-adapter"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; -import ovenIr from "../../../../ovens/checklist/checklist.ir.json"; import "./ChecklistDashboard.css"; -export function ChecklistOvenView({ data }: { data: ChecklistProgressData }) { +export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; ir: ResolvedOvenIr }) { const payload = useMemo(() => adaptChecklist(data), [data]); 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/CustomOvenView/CustomOvenView.tsx b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx index 9f1e435..d67448c 100644 --- a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx +++ b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx @@ -14,6 +14,13 @@ function unwrapPayload(raw: unknown) { return raw && typeof raw === "object" && "payload" in raw ? (raw as { payload: unknown }).payload : raw; } +export function CustomOvenRuntime({ burnlistId, loaded }: { burnlistId?: string; loaded: LoadedOven }) { + if (burnlistId) { + return <>; + } + return ; +} + export function CustomOvenView() { const selection = customOvenSelection(); const [loaded, setLoaded] = useState(null); @@ -53,6 +60,5 @@ export function CustomOvenView() { if (error) return ; if (!loaded) return ; - const runtime = ; - return selection?.burnlistId ? <>{runtime} : runtime; + return ; } diff --git a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs index 35567e6..d240bcf 100644 --- a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs +++ b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs @@ -2,12 +2,11 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; 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 { compileOven } from "../../../../src/ovens/dsl/oven-compile.mjs"; -const runtimePath = new URL("../../oven/runtime/OvenRuntime.tsx", import.meta.url).pathname; +const componentPath = new URL("./CustomOvenView.tsx", import.meta.url).pathname; const sourceDir = new URL("../../", import.meta.url).pathname; const libPath = new URL("../../lib", import.meta.url).pathname; const ovenPath = new URL("../../oven", import.meta.url).pathname; @@ -17,12 +16,12 @@ const ovenSource = ` { +test("custom Oven runtime modes preserve live standalone polling and controlled Burnlist data", { timeout: 20_000 }, async () => { const outputDir = await mkdtemp(join(process.cwd(), ".custom-oven-render-test-")); try { - const runtimeOutput = join(outputDir, "OvenRuntime.mjs"); + const runtimeOutput = join(outputDir, "CustomOvenView.mjs"); await build({ - entryPoints: [runtimePath], + entryPoints: [componentPath], bundle: true, format: "esm", outfile: runtimeOutput, @@ -32,17 +31,30 @@ test("a custom Oven runtime renders author-shaped data values", { timeout: 20_00 packages: "external", target: "node18", }); - const { OvenRuntime } = await import(`${new URL(`file://${runtimeOutput}`).href}?test=${Date.now()}`); + const { CustomOvenRuntime } = await import(`${new URL(`file://${runtimeOutput}`).href}?test=${Date.now()}`); const compiled = compileOven(ovenSource, { file: "widget-oven.oven" }); assert.equal(compiled.ok, true, compiled.ok ? "" : JSON.stringify(compiled.diagnostics)); if (!compiled.ok) return; - const markup = renderToStaticMarkup(createElement(OvenRuntime, { - ir: compiled.ir, - payload: { widget: { name: "Sprockets", count: 42 } }, - })); + const payload = { widget: { name: "Sprockets", count: 42 } }; + const ir = { ...compiled.ir, refreshSeconds: 7 }; + const standalone = CustomOvenRuntime({ loaded: { ir, payload } }); + assert.equal(standalone.props.ir, ir); + assert.equal(standalone.props.initialPayload, payload); + assert.equal("payload" in standalone.props, false); + assert.equal(standalone.props.ir.refreshSeconds, 7); + assert.equal(typeof standalone.props.adapt, "function"); + + const markup = renderToStaticMarkup(standalone); assert.match(markup, /Sprockets/u); assert.match(markup, />42 ({ namespace: "test-oven-ir", path: resolve(dirname(args.importer), args.path) })); - context.onLoad({ filter: /\.ir\.json$/, namespace: "test-oven-ir" }, async (args) => { - const sourcePath = args.path.replace(/\.ir\.json$/u, ".oven"); - const compiled = compileOven(await readFile(sourcePath, "utf8"), { file: sourcePath }); - if (!compiled.ok) throw new Error(JSON.stringify(compiled.diagnostics)); - return { contents: JSON.stringify(compiled.ir), loader: "json" }; - }); - }, -}; +async function ovenIr(id) { + const path = new URL(`../../../../ovens/${id}/${id}.oven`, import.meta.url); + const compiled = compileOven(await readFile(path, "utf8"), { file: path.pathname }); + if (!compiled.ok) throw new Error(JSON.stringify(compiled.diagnostics)); + return compiled.ir; +} test("live Differential Testing pages retain the scoped shell required by their dashboard CSS", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".differential-testing-oven-test-")); @@ -37,12 +31,11 @@ test("live Differential Testing pages retain the scoped shell required by their alias: { "@": sourcePath, "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", - plugins: [ovenIrPlugin], target: "node18", }); const { DifferentialTestingOvenPage, PerformanceTracingOvenPage } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); - const differential = renderToStaticMarkup(createElement(DifferentialTestingOvenPage)); - const performance = renderToStaticMarkup(createElement(PerformanceTracingOvenPage)); + const differential = renderToStaticMarkup(createElement(DifferentialTestingOvenPage, { ir: await ovenIr("differential-testing") })); + const performance = renderToStaticMarkup(createElement(PerformanceTracingOvenPage, { ir: await ovenIr("performance-tracing") })); assert.match(differential, /^
Loading…<\/div><\/div>$/u); assert.match(performance, /^
Loading…<\/div><\/div>$/u); diff --git a/dashboard/src/components/DifferentialTestingOven/DifferentialTestingOven.tsx b/dashboard/src/components/DifferentialTestingOven/DifferentialTestingOven.tsx index f511e41..930ed8c 100644 --- a/dashboard/src/components/DifferentialTestingOven/DifferentialTestingOven.tsx +++ b/dashboard/src/components/DifferentialTestingOven/DifferentialTestingOven.tsx @@ -1,10 +1,9 @@ import { useEffect, type ReactNode } from "react"; +import type { ResolvedOvenIr } from "@hooks"; import { adaptPerformanceTracingReport } from "@lib"; import { adaptDifferentialTesting, type DifferentialTestingData } from "../../lib/differential-testing-adapter"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; import { withDifferentialTestingEnvelope } from "@/oven/runtime/oven-payload-metadata"; -import differentialTestingIr from "../../../../ovens/differential-testing/differential-testing.ir.json"; -import performanceTracingIr from "../../../../ovens/performance-tracing/performance-tracing.ir.json"; type ResponseEnvelope = { payload: unknown; fieldPage?: unknown; frameDeltaMetrics?: unknown }; @@ -33,10 +32,10 @@ function DifferentialTestingShell({ children, performanceTracing = false }: { ch return
{children}
; } -export function DifferentialTestingOvenPage() { - return ; +export function DifferentialTestingOvenPage({ ir }: { ir: ResolvedOvenIr }) { + return ; } -export function PerformanceTracingOvenPage() { - return ; +export function PerformanceTracingOvenPage({ ir }: { ir: ResolvedOvenIr }) { + return ; } diff --git a/dashboard/src/components/ModelLab/ModelLab.test.mjs b/dashboard/src/components/ModelLab/ModelLab.test.mjs new file mode 100644 index 0000000..ba2001a --- /dev/null +++ b/dashboard/src/components/ModelLab/ModelLab.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +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 { compileOven } from "../../../../src/ovens/dsl/oven-compile.mjs"; + +const componentPath = new URL("./ModelLab.tsx", import.meta.url).pathname; +const sourceDir = new URL("../..", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("../../oven", import.meta.url).pathname; +const shippedSourcePath = new URL("../../../../ovens/model-lab/model-lab.oven", import.meta.url); + +const payload = { + schema: "burnlist-model-lab-data@1", + generatedAt: "2026-07-22T10:00:00Z", + project: { id: "fixture", label: "Fixture Project" }, + surface: { title: "Fixture Model Lab", url: "http://127.0.0.1:5173/model-lab.html" }, + model: { + id: "player-a", + actor: { id: "actor-a", name: "Ada", country: "DE", shirtNumber: 7, sourceTeamSlot: "A" }, + animations: [{ id: "idle", slotId: 0, symbol: "idle", firstFrameIndex: 0, firstFrameId: "idle-0", frameCount: 2 }], + frameIndex: 0, + frameId: "idle-0", + frameCount: 2, + polygonCount: 12, + leafCount: 4, + leafTag: "s", + topologyMode: "stable-frame-set", + lodCount: 1, + droppedSourcePolygonCount: 0, + topologyHash: "a".repeat(64), + frameSetHash: "b".repeat(64), + runtimeConstruction: { + assetBuildCount: 0, + geometryBuildCount: 0, + materialBuildCount: 0, + sourceParseCount: 0, + topologyBuildCount: 0, + }, + }, + evidence: { + manifestSha256: "c".repeat(64), + renderPublicationSha256: "d".repeat(64), + prepareInputsSha256: "e".repeat(64), + }, +}; + +async function compile(source, file) { + const result = compileOven(source, { file }); + assert.equal(result.ok, true, result.ok ? "" : JSON.stringify(result.diagnostics)); + return result.ir; +} + +async function loadComponent() { + const outputDir = await mkdtemp(join(process.cwd(), ".model-lab-render-test-")); + const outputPath = join(outputDir, "ModelLab.mjs"); + await build({ + entryPoints: [componentPath], + bundle: true, + format: "esm", + outfile: outputPath, + platform: "node", + alias: { "@": sourceDir, "@lib": libPath, "@oven": ovenPath }, + jsx: "automatic", + packages: "external", + target: "node18", + }); + return { + component: await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`), + cleanup: () => rm(outputDir, { force: true, recursive: true }), + }; +} + +test("the shipped Model Lab source compiles to the closed specialized widget", async () => { + const source = await readFile(shippedSourcePath, "utf8"); + const ir = await compile(source, shippedSourcePath.pathname); + assert.equal(ir.root.length, 1); + assert.equal(ir.root[0].kind, "model-lab-view"); + assert.equal(ir.root[0].attributes.source, "/"); + assert.deepEqual(ir.requirements.components, ["model-lab-view"]); +}); + +test("the actual Model Lab page follows its resolved IR", { timeout: 20_000 }, async () => { + const { component, cleanup } = await loadComponent(); + try { + const shippedIr = await compile(await readFile(shippedSourcePath, "utf8"), shippedSourcePath.pathname); + const alternateIr = await compile(` + + + + `, "vendored/model-lab.oven"); + const render = (ir) => renderToStaticMarkup(createElement(component.ModelLabPageContent, { + error: "", + ir, + loading: false, + payload, + })); + + const shipped = render(shippedIr); + assert.match(shipped, /class="model-lab-oven"/u); + assert.match(shipped, /Fixture Project · player-a/u); + assert.match(shipped, /src="http:\/\/127\.0\.0\.1:5173\/model-lab\.html\?embedded=1&model=player-a&frame=0"/u); + + const vendored = render(alternateIr); + assert.match(vendored, /Vendored Model Lab layout/u); + assert.doesNotMatch(vendored, /class="model-lab-oven"/u); + assert.doesNotMatch(vendored, /Fixture Project · player-a/u); + } finally { + await cleanup(); + } +}); + +test("Model Lab polling keeps repository scoping in its data URL", async () => { + const { component, cleanup } = await loadComponent(); + try { + assert.equal(component.MODEL_LAB_POLL_MS, 2_000); + assert.equal(component.modelLabDataUrl(null), "/api/oven-data/model-lab"); + assert.equal(component.modelLabDataUrl("repo/key"), "/api/oven-data/model-lab?repoKey=repo%2Fkey"); + } finally { + await cleanup(); + } +}); diff --git a/dashboard/src/components/ModelLab/ModelLab.tsx b/dashboard/src/components/ModelLab/ModelLab.tsx index 71936b4..61c46df 100644 --- a/dashboard/src/components/ModelLab/ModelLab.tsx +++ b/dashboard/src/components/ModelLab/ModelLab.tsx @@ -1,159 +1,34 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useState, type ComponentProps } from "react"; import { ovenRepoKey } from "@lib"; +import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; +import type { ModelLabPayload } from "@/oven/ModelLabView"; -type RuntimeConstruction = { - assetBuildCount: number; - geometryBuildCount: number; - materialBuildCount: number; - sourceParseCount: number; - topologyBuildCount: number; -}; +type ModelLabIr = ComponentProps["ir"]; -type ModelAnimation = { - id: string; - slotId: number; - symbol: string; - firstFrameIndex: number; - firstFrameId: string; - frameCount: number; -}; +export const MODEL_LAB_POLL_MS = 2_000; -type ComparisonImage = { - url: string; - sha256: string; - width: number; - height: number; -}; - -type ModelLabComparison = { - schema: "burnlist-model-lab-comparison@1"; - frameId: string; - referenceLabel: "Native"; - candidateLabel: "Model Lab"; - channelThreshold: number; - pass: boolean; - reportSha256: string; - angles: Array<{ - angle: 0 | 45 | 180; - native: ComparisonImage; - candidate: ComparisonImage; - diff: ComparisonImage; - metrics: { - meanAbsDelta: number; - rmsDelta: number; - maxAbsDelta: number; - changedPixelRatio: number; - pass: boolean; - }; - }>; -}; - -type ModelLabPayload = { - schema: "burnlist-model-lab-data@1"; - generatedAt: string; - project: { id: string; label: string }; - surface: { title: string; url: string }; - model: { - id: string; - actor: { - id: string; - name: string; - country: string; - shirtNumber: number; - sourceTeamSlot: "A" | "B"; - }; - animations: ModelAnimation[]; - frameIndex: number; - frameId: string; - frameCount: number; - polygonCount: number; - leafCount: number; - leafTag: "s"; - topologyMode: "stable-frame-set"; - lodCount: 1; - droppedSourcePolygonCount: number; - topologyHash: string; - frameSetHash: string; - runtimeConstruction: RuntimeConstruction; - }; - evidence: { - manifestSha256: string; - renderPublicationSha256: string; - prepareInputsSha256: string; - }; - comparison?: ModelLabComparison; -}; - -type RuntimeStats = { - ready: boolean; - modelId: string; - frameIndex: number; - frameId: string; - frameCount: number; - leafCount: number; - visibleLeafCount: number; - renderedLeafCount: number; - stableRootIdentity: boolean; - stableLeafIdentityCount: number; - connectedLeafCount: number; - childListMutationCount: number; - droppedSourcePolygonCount: number; - leafTags: string[]; - runtimeConstruction: RuntimeConstruction; -}; - -type RuntimeMessage = { - schema: "polycss-model-lab-state@1"; - status: "ready" | "error"; - stats?: RuntimeStats; - error?: string; -}; - -const MODEL_LAB_COMMAND_SCHEMA = "polycss-model-lab-command@1"; - -function shortHash(value: string) { - return `${value.slice(0, 10)}…${value.slice(-8)}`; -} - -function zeroRuntimeConstruction(value: RuntimeConstruction | undefined) { - return Boolean(value) && Object.values(value!).every((count) => count === 0); +export function modelLabDataUrl(repoKey: string | null) { + return `/api/oven-data/model-lab${repoKey ? `?repoKey=${encodeURIComponent(repoKey)}` : ""}`; } -function metric(label: string, value: string, tone = "") { - return ( -
- {label} - {value} -
- ); -} - -function comparisonFigure( - label: string, - image: ComparisonImage, - detail?: string, -) { - return ( -
-
- {label} - {detail && {detail}} -
- {label} -
- ); +export function ModelLabPageContent({ error, ir, loading, payload }: { + error: string; + ir: ModelLabIr; + loading: boolean; + payload: ModelLabPayload | null; +}) { + if (loading && !payload) { + return
Loading the bound Model Lab surface…
; + } + if (error && !payload) { + return
{error}
; + } + if (!payload) return null; + return ; } -export function ModelLabPage() { - const iframe = useRef(null); +export function ModelLabPage({ ir }: { ir: ModelLabIr }) { const [payload, setPayload] = useState(null); - const [runtime, setRuntime] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); @@ -164,9 +39,7 @@ export function ModelLabPage() { if (inFlight) return; inFlight = true; try { - const repoKey = ovenRepoKey(); - const query = repoKey ? `?repoKey=${encodeURIComponent(repoKey)}` : ""; - const response = await fetch(`/api/oven-data/model-lab${query}`, { cache: "no-store" }); + const response = await fetch(modelLabDataUrl(ovenRepoKey()), { cache: "no-store" }); const document = await response.json(); if (!response.ok) throw new Error(document.error ?? "Could not load Model Lab."); if (document.validated !== true) throw new Error("Model Lab data was not validated by the Oven."); @@ -182,186 +55,12 @@ export function ModelLabPage() { } }; void refresh(); - const timer = window.setInterval(refresh, 2_000); + const timer = window.setInterval(refresh, MODEL_LAB_POLL_MS); return () => { cancelled = true; window.clearInterval(timer); }; }, []); - const surfaceUrl = useMemo(() => { - if (!payload) return ""; - const url = new URL(payload.surface.url); - url.searchParams.set("embedded", "1"); - url.searchParams.set("model", payload.model.id); - url.searchParams.set("frame", String(payload.model.frameIndex)); - return url.href; - }, [payload?.surface.url, payload?.model.id, payload?.model.frameIndex]); - - useEffect(() => { - if (!surfaceUrl) return; - setRuntime(null); - const expectedOrigin = new URL(surfaceUrl).origin; - const receive = (event: MessageEvent) => { - if (event.source !== iframe.current?.contentWindow || event.origin !== expectedOrigin) return; - if (event.data?.schema !== "polycss-model-lab-state@1") return; - setRuntime(event.data); - }; - window.addEventListener("message", receive); - return () => window.removeEventListener("message", receive); - }, [surfaceUrl]); - - if (loading && !payload) { - return
Loading the bound Model Lab surface…
; - } - if (error && !payload) { - return
{error}
; - } - if (!payload) return null; - - const live = runtime?.stats; - const leafCount = live?.leafCount ?? payload.model.leafCount; - const stableLeaves = live?.stableLeafIdentityCount; - const leafTagsAreS = live ? live.leafTags.length === leafCount && live.leafTags.every((tag) => tag === "s") : true; - const runtimeZero = zeroRuntimeConstruction(live?.runtimeConstruction ?? payload.model.runtimeConstruction); - const liveReady = runtime?.status === "ready" && live?.ready === true; - const frameIndex = live?.frameIndex ?? payload.model.frameIndex; - const frameId = live?.frameId ?? payload.model.frameId; - const activeAnimation = payload.model.animations.find(({ firstFrameIndex, frameCount }) => ( - frameIndex >= firstFrameIndex && frameIndex < firstFrameIndex + frameCount - )); - const selectAnimation = (animationId: string) => { - const animation = payload.model.animations.find(({ id }) => id === animationId); - if (!animation || !iframe.current?.contentWindow) return; - iframe.current.contentWindow.postMessage({ - schema: MODEL_LAB_COMMAND_SCHEMA, - command: "set-frame", - frameIndex: animation.firstFrameIndex, - }, new URL(surfaceUrl).origin); - }; - - return ( -
-
-

{payload.project.label} · {payload.model.id}

-

- {liveReady ? "Live DOM" : "Connecting"} - - one frameset - - no LOD - - <s> × {leafCount} -

-
- -
-
-