From b3b8cf92b9aa9d07de64d6ad8f8dc9ebcb1bcbcc Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 00:15:12 +0200 Subject: [PATCH 01/30] refactor: route ovens through a code-owned handler registry --- .../engine/differential-testing-handler.mjs | 231 ++++++++++++ scripts/verify.mjs | 17 +- src/ovens/built-in-handlers.mjs | 2 + src/ovens/handlers/checklist.mjs | 4 + src/ovens/handlers/generic-json-handler.mjs | 22 ++ src/ovens/oven-registry.mjs | 23 ++ src/ovens/oven-registry.test.mjs | 15 + src/server/burnlist-dashboard-server.mjs | 331 +++--------------- src/server/dashboard-routes.test.mjs | 41 ++- 9 files changed, 397 insertions(+), 289 deletions(-) create mode 100644 ovens/differential-testing/engine/differential-testing-handler.mjs create mode 100644 src/ovens/built-in-handlers.mjs create mode 100644 src/ovens/handlers/checklist.mjs create mode 100644 src/ovens/handlers/generic-json-handler.mjs create mode 100644 src/ovens/oven-registry.mjs create mode 100644 src/ovens/oven-registry.test.mjs diff --git a/ovens/differential-testing/engine/differential-testing-handler.mjs b/ovens/differential-testing/engine/differential-testing-handler.mjs new file mode 100644 index 0000000..0189947 --- /dev/null +++ b/ovens/differential-testing/engine/differential-testing-handler.mjs @@ -0,0 +1,231 @@ +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 { readTextFileWithLimit, safeStat } from "../../../src/server/fs-safe.mjs"; +import { assertDifferentialTestingData } from "./differential-testing-data-contract.mjs"; +import { + DIFFERENTIAL_TESTING_PAGE_SCHEMA, + isDifferentialTestingBundle, + queryDifferentialTestingFieldPage, + readDifferentialTestingBundleManifest, + readDifferentialTestingBundleScenario, +} from "./differential-testing-transport.mjs"; + +function differentialTestingIndexCache(ctx, path) { + const readPath = resolve(realpathSync(dirname(path)), basename(path)); + const stat = safeStat(readPath); + if (!stat?.isFile()) throw new Error("configured Differential Testing data is missing"); + const signature = `${readPath}\0${stat.ino}\0${stat.size}\0${stat.mtimeMs}`; + const cached = ctx.cache.get("index"); + if (cached?.signature === signature) return cached; + const source = readTextFileWithLimit(readPath, ctx.maxOvenDataBytes, "Oven differential-testing data"); + const document = JSON.parse(source); + let index; + if (isDifferentialTestingBundle(document)) { + const bundle = readDifferentialTestingBundleManifest(readPath, { maxDocumentBytes: ctx.maxOvenDataBytes }); + const emptyPayload = bundle.selectedScenarioId === null ? bundle.manifest.emptyData : null; + const emptySource = emptyPayload ? JSON.stringify(emptyPayload) : ""; + index = { + kind: "bundle", + signature, + readPath: bundle.readPath, + source: emptySource, + sourceBytes: Buffer.byteLength(emptySource), + etag: `W/\"dtb-${bundle.sha256}\"`, + selectedScenarioId: bundle.selectedScenarioId, + scenarios: structuredClone(bundle.scenarios), + bundle, + scenarioDocuments: new Map(), + scenarioResponses: new Map(), + responseExtras: emptyPayload ? { + transport: { + schema: DIFFERENTIAL_TESTING_PAGE_SCHEMA, + bundleSha256: bundle.sha256, + scenarioSha256: null, + }, + fieldPage: null, + frameDeltaMetrics: null, + } : null, + }; + } else { + assertDifferentialTestingData(document); + index = { + kind: "legacy", + signature, + readPath, + source, + sourceBytes: stat.size, + etag: `W/\"dt-${stat.ino}-${stat.size}-${Math.trunc(stat.mtimeMs)}\"`, + selectedScenarioId: document.scenarioCatalog.selectedScenarioId, + scenarios: structuredClone(document.scenarioCatalog.scenarios), + scenarioResponses: new Map(), + }; + } + ctx.cache.set("index", index); + return index; +} + +function differentialTestingQueryValue(searchParams, name) { + const values = searchParams.getAll(name); + if (values.length > 1) throw Object.assign(new Error(`${name} must be supplied at most once`), { status: 400 }); + return values[0] ?? null; +} + +function differentialTestingIntegerQuery(searchParams, name, fallback) { + const value = differentialTestingQueryValue(searchParams, name); + if (value === null) return fallback; + if (!/^(?:0|[1-9]\d*)$/u.test(value)) throw Object.assign(new Error(`${name} must be a non-negative integer`), { status: 400 }); + const number = Number(value); + if (!Number.isSafeInteger(number)) throw Object.assign(new Error(`${name} must be a safe integer`), { status: 400 }); + return number; +} + +function differentialTestingBundleScenario(ctx, index, scenarioId) { + const cached = index.scenarioDocuments.get(scenarioId); + if (cached) return cached; + const scenario = readDifferentialTestingBundleScenario(index.bundle, scenarioId, { maxDocumentBytes: ctx.maxOvenDataBytes }); + scenario.source = JSON.stringify(scenario.data); + scenario.sourceBytes = Buffer.byteLength(scenario.source); + index.scenarioDocuments.set(scenarioId, scenario); + return scenario; +} + +function differentialTestingBundleResponse(ctx, index, scenarioId, searchParams) { + const search = differentialTestingQueryValue(searchParams, "search") ?? ""; + const filter = differentialTestingQueryValue(searchParams, "filter") ?? "all"; + const scenario = differentialTestingBundleScenario(ctx, index, scenarioId); + const sort = differentialTestingQueryValue(searchParams, "sort") + ?? (scenario.data.telemetry?.status === "comparable" ? "changed" : "default"); + const page = differentialTestingIntegerQuery(searchParams, "page", 0); + const pageSize = differentialTestingIntegerQuery(searchParams, "pageSize", 25); + const fieldPage = queryDifferentialTestingFieldPage(scenario, { search, filter, sort, page, pageSize }); + const queryKey = new URLSearchParams({ search: fieldPage.search, filter: fieldPage.filter, sort: fieldPage.sort, page: String(fieldPage.page), pageSize: String(fieldPage.pageSize) }).toString(); + const etagDigest = createHash("sha256").update(index.bundle.sha256).update("\0").update(scenario.scenarioSha256).update("\0").update(queryKey).digest("hex"); + return { + signature: `${index.signature}\0${scenario.scenarioSha256}\0${queryKey}`, + readPath: index.readPath, + source: scenario.source, + sourceBytes: scenario.sourceBytes, + etag: `W/\"dtb-${etagDigest}\"`, + selectedScenarioId: scenarioId, + responseExtras: { + transport: { schema: DIFFERENTIAL_TESTING_PAGE_SCHEMA, bundleSha256: index.bundle.sha256, scenarioSha256: scenario.scenarioSha256 }, + fieldPage, + frameDeltaMetrics: scenario.frameDeltaMetrics, + }, + }; +} + +function differentialTestingScenarioCache(ctx, index, scenarioId) { + const cached = index.scenarioResponses.get(scenarioId); + const scenariosDir = resolve(dirname(index.readPath), "scenarios"); + const scenarioPath = resolve(scenariosDir, `${scenarioId}.json`); + const scenarioRelativePath = relative(scenariosDir, scenarioPath); + if (!scenarioRelativePath || scenarioRelativePath.startsWith("..") || resolve(scenariosDir, scenarioRelativePath) !== scenarioPath) { + throw Object.assign(new Error("scenario path escapes the published bundle"), { status: 400 }); + } + const stat = safeStat(scenarioPath); + if (!stat?.isFile()) throw Object.assign(new Error(`published data for scenario ${scenarioId} is missing`), { status: 404 }); + 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); + if (sourcePayload.scenarioCatalog.selectedScenarioId !== scenarioId) { + throw Object.assign(new Error(`scenario file ${scenarioId} selects ${sourcePayload.scenarioCatalog.selectedScenarioId}`), { status: 422 }); + } + const indexScenario = index.scenarios.find((scenario) => scenario.id === scenarioId); + const payloadScenario = sourcePayload.scenarioCatalog.scenarios.find((scenario) => scenario.id === scenarioId); + if (JSON.stringify(payloadScenario) !== JSON.stringify(indexScenario)) { + 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); + const source = JSON.stringify(payload); + const result = { + signature, + readPath: scenarioPath, + source, + sourceBytes: Buffer.byteLength(source), + etag: `W/\"dt-${stat.ino}-${stat.size}-${Math.trunc(stat.mtimeMs)}-${index.etag.slice(3, -1)}\"`, + selectedScenarioId: scenarioId, + }; + index.scenarioResponses.set(scenarioId, result); + return result; +} + +function sendDifferentialTestingData(req, res, cached) { + if (req.headers["if-none-match"] === cached.etag) { + res.writeHead(304, { etag: cached.etag, "cache-control": "no-store" }); + res.end(); + return; + } + const prefix = `${JSON.stringify({ ovenId: "differential-testing", path: cached.readPath, scenarioId: cached.selectedScenarioId, ...(cached.responseExtras || {}) }).slice(0, -1)},\"payload\":`; + const suffix = "}"; + res.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + etag: cached.etag, + "content-length": Buffer.byteLength(prefix) + cached.sourceBytes + Buffer.byteLength(suffix), + }); + res.write(prefix); + res.write(cached.source); + res.end(suffix); +} + +export const differentialTestingHandler = Object.freeze({ + id: "differential-testing", + warmIntervalMs: 1_000, + + dashboardEntries(ctx) { + const path = ctx.ovenDataBindings.get("differential-testing"); + if (!path) return []; + const index = differentialTestingIndexCache(ctx, path); + const matchedRepo = ctx.discoveredRepos() + .filter((entry) => index.readPath === entry.root || index.readPath.startsWith(`${entry.root}/`)) + .sort((left, right) => right.root.length - left.root.length)[0] ?? null; + const repo = matchedRepo?.name ?? "differential-testing"; + return index.scenarios.map((scenario) => ({ + id: scenario.id, repo, repoRoot: matchedRepo?.root ?? null, title: scenario.label, + 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)}`, + progressLabel: `${scenario.frameCount} frames`, + })); + }, + + serveData(ctx) { + const index = differentialTestingIndexCache(ctx, ctx.bindingPath); + const requestedScenarioIds = ctx.url.searchParams.getAll("scenario"); + if (requestedScenarioIds.length > 1) throw Object.assign(new Error("scenario must be supplied at most once"), { status: 400 }); + const requestedScenarioId = requestedScenarioIds[0] ?? ""; + if (!requestedScenarioId || requestedScenarioId === index.selectedScenarioId) { + const selected = index.kind === "bundle" && index.selectedScenarioId + ? differentialTestingBundleResponse(ctx, index, index.selectedScenarioId, ctx.url.searchParams) + : index; + sendDifferentialTestingData(ctx.req, ctx.res, selected); + return; + } + if (!/^[a-f0-9]{16}$/u.test(requestedScenarioId)) throw Object.assign(new Error("scenario must be a lowercase 16-character hexadecimal id"), { status: 400 }); + if (!index.scenarios.some((scenario) => scenario.id === requestedScenarioId)) { + throw Object.assign(new Error(`scenario ${requestedScenarioId} is not in the published catalog`), { status: 404 }); + } + const selected = index.kind === "bundle" + ? differentialTestingBundleResponse(ctx, index, requestedScenarioId, ctx.url.searchParams) + : differentialTestingScenarioCache(ctx, index, requestedScenarioId); + sendDifferentialTestingData(ctx.req, ctx.res, selected); + }, + + warm(ctx) { + const path = ctx.ovenDataBindings.get("differential-testing"); + if (!path) return; + try { + differentialTestingIndexCache(ctx, path); + } catch { + // The request path reports validation errors; background warming remains silent. + } + }, +}); + +registerOvenHandler("differential-testing", differentialTestingHandler); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 8e8b0a9..8ff5bb5 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -260,14 +260,18 @@ function assertPublishablePackage() { } } -const jsFiles = [ +const jsFiles = [...new Set([ ...walkFiles(resolve(repoRoot, "bin"), (path) => path.endsWith(".mjs")), ...walkFiles(resolve(repoRoot, "scripts"), (path) => path.endsWith(".mjs")), ...walkFiles(resolve(repoRoot, "src"), (path) => path.endsWith(".mjs")), ...walkFiles(resolve(repoRoot, "ovens/differential-testing/engine"), (path) => path.endsWith(".mjs")), + resolve(repoRoot, "src/ovens/oven-registry.mjs"), + resolve(repoRoot, "src/ovens/built-in-handlers.mjs"), + resolve(repoRoot, "src/ovens/handlers/generic-json-handler.mjs"), + resolve(repoRoot, "ovens/differential-testing/engine/differential-testing-handler.mjs"), resolve(repoRoot, "ovens/differential-testing/renderer/differential-testing-progress-chart.js"), resolve(repoRoot, "ovens/differential-testing/renderer/differential-testing-renderer.js"), -].sort(); +])].sort(); for (const file of jsFiles) { run(process.execPath, ["--check", relative(repoRoot, file)]); @@ -288,8 +292,10 @@ assertSourceIncludes("src/server/repo-map.mjs", 'REPO_MAP_SCHEMA = "burnlist-rep assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", 'assertKnownKeys(value, new Set(["id", "name", "instructions", "detail"]), "Oven")', "Oven creation does not reject fields outside the strict Oven contract."); assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", 'assertKnownKeys(value, new Set(["ovenId", "repoRoot", "title", "objective"]), "Burn run")', "Burn run creation does not reject fields outside the strict Oven contract."); assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", "ovenId(record.ovenId);", "Burn run reads do not require the canonical ovenId."); -assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", "assertDifferentialTestingData(payload)", "Differential Testing data is not validated at the server boundary."); -assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", 'ovenName: "Differential Testing"', "Differential Testing scenarios are missing from the shared dashboard table."); +assertSourceIncludes("ovens/differential-testing/engine/differential-testing-handler.mjs", "assertDifferentialTestingData(payload)", "Differential Testing data is not validated at the server boundary."); +assertSourceIncludes("ovens/differential-testing/engine/differential-testing-handler.mjs", 'ovenName: "Differential Testing"', "Differential Testing scenarios are missing from the shared dashboard table."); +assertSourceIncludes("ovens/differential-testing/engine/differential-testing-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."); assertSourceIncludes("dashboard/src/lib/hrefs.ts", '? value! : "active"', "Dashboard table is not filtered to Active by default."); assertSourceIncludes("dashboard/src/components/ProjectGroup/BurnlistTable.tsx", 'Oven', "Shared dashboard table does not identify each row's Oven."); assertSourceIncludes("bin/burnlist.mjs", "--oven-data ", "Burnlist CLI is missing read-only Oven data binding help."); @@ -362,7 +368,6 @@ assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-r assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'role="button" tabindex="0" aria-expanded=', "Differential Testing rows do not preserve the expand interaction contract."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'placeholder="Search Fields..."', "Differential Testing does not preserve the canonical search control."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'data-driving-parity-chart="delta"', "Differential Testing does not preserve the canonical Value and Delta controls."); -assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", "queryDifferentialTestingFieldPage", "Differential Testing server is missing bounded field-page transport."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'data-driving-parity-sort="improved"', "Differential Testing does not preserve the canonical Changed control."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'data-driving-parity-filter="failing"', "Differential Testing does not preserve the canonical Failed control."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'class="hybrid-cell hybrid-field"', "Differential Testing does not preserve the canonical hybrid field cell."); @@ -416,11 +421,13 @@ assertPublishablePackage(); run(process.execPath, [ "--test", "src/server/dashboard-routes.test.mjs", + "src/ovens/oven-registry.test.mjs", "src/server/projects.test.mjs", "src/server/projects-api.test.mjs", "ovens/differential-testing/engine/differential-testing-adapter-sdk.test.mjs", "ovens/differential-testing/engine/differential-testing-contract.test.mjs", "ovens/differential-testing/engine/differential-testing-data-contract.test.mjs", + "ovens/differential-testing/engine/differential-testing-transport-server.test.mjs", "src/server/discovery.test.mjs", "src/server/plan-model.test.mjs", "src/cli/lifecycle-cli.test.mjs", diff --git a/src/ovens/built-in-handlers.mjs b/src/ovens/built-in-handlers.mjs new file mode 100644 index 0000000..1e6a06e --- /dev/null +++ b/src/ovens/built-in-handlers.mjs @@ -0,0 +1,2 @@ +import "./handlers/checklist.mjs"; +import "../../ovens/differential-testing/engine/differential-testing-handler.mjs"; diff --git a/src/ovens/handlers/checklist.mjs b/src/ovens/handlers/checklist.mjs new file mode 100644 index 0000000..14ffb83 --- /dev/null +++ b/src/ovens/handlers/checklist.mjs @@ -0,0 +1,4 @@ +import { registerOvenHandler } from "../oven-registry.mjs"; +import { genericJsonHandler } from "./generic-json-handler.mjs"; + +registerOvenHandler("checklist", genericJsonHandler); diff --git a/src/ovens/handlers/generic-json-handler.mjs b/src/ovens/handlers/generic-json-handler.mjs new file mode 100644 index 0000000..b2cd642 --- /dev/null +++ b/src/ovens/handlers/generic-json-handler.mjs @@ -0,0 +1,22 @@ +import { readTextFileWithLimit, safeStat } from "../../server/fs-safe.mjs"; + +export const genericJsonHandler = Object.freeze({ + 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`)); + return { ovenId: id, path: bindingPath, payload }; + }, + + dashboardEntries({ oven, discoverBurnlists }) { + if (oven.id !== "checklist") return []; + return discoverBurnlists().map((entry) => ({ + ...entry, + ovenId: "checklist", + ovenName: "Checklist", + href: `/${encodeURIComponent(entry.repo)}/${encodeURIComponent(entry.id)}`, + progressLabel: `${entry.done}/${entry.total} done`, + })); + }, +}); diff --git a/src/ovens/oven-registry.mjs b/src/ovens/oven-registry.mjs new file mode 100644 index 0000000..a077277 --- /dev/null +++ b/src/ovens/oven-registry.mjs @@ -0,0 +1,23 @@ +import { ovenId } from "./oven-contract.mjs"; + +// Oven handlers are code-owned adapters for declared Oven packages. A handler may +// provide dashboardEntries(ctx), serveData(ctx), and warm(ctx) with warmIntervalMs. +// serveData may write directly to ctx.res, or return a value for the server to JSON +// serialize. Context contains only the request-specific values each hook needs. +const handlers = new Map(); + +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.`); + if (handlers.has(normalizedId)) throw new Error(`Oven handler for ${normalizedId} is already registered.`); + handlers.set(normalizedId, handler); + return handler; +} + +export function getOvenHandler(id) { + return handlers.get(id) ?? null; +} + +export function listOvenHandlers() { + return [...handlers.values()]; +} diff --git a/src/ovens/oven-registry.test.mjs b/src/ovens/oven-registry.test.mjs new file mode 100644 index 0000000..676b6f4 --- /dev/null +++ b/src/ovens/oven-registry.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getOvenHandler, listOvenHandlers, registerOvenHandler } from "./oven-registry.mjs"; + +test("the Oven handler registry validates and retrieves code-owned handlers", () => { + const handler = {}; + registerOvenHandler("registry-test", handler); + + assert.equal(getOvenHandler("registry-test"), handler); + assert.equal(listOvenHandlers().includes(handler), true); + assert.equal(getOvenHandler("not-registered"), null); + assert.equal(getOvenHandler("Invalid id"), null); + assert.throws(() => registerOvenHandler("registry-test", {}), /already registered/u); + assert.throws(() => registerOvenHandler("Invalid id", {}), /lowercase slug/u); +}); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 6b4d03d..46a44fe 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { createHash, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { existsSync, @@ -25,14 +25,9 @@ import { normalizeOvenPackage, ovenId, } from "../ovens/oven-contract.mjs"; -import { assertDifferentialTestingData } from "../../ovens/differential-testing/engine/differential-testing-data-contract.mjs"; -import { - DIFFERENTIAL_TESTING_PAGE_SCHEMA, - isDifferentialTestingBundle, - queryDifferentialTestingFieldPage, - readDifferentialTestingBundleManifest, - readDifferentialTestingBundleScenario, -} from "../../ovens/differential-testing/engine/differential-testing-transport.mjs"; +import "../ovens/built-in-handlers.mjs"; +import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; +import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { readTextFileWithLimit, safeStat } from "./fs-safe.mjs"; import { @@ -112,7 +107,7 @@ const legacyRunsDir = args.has("runs-dir") ? resolve(launchCwd, args.get("runs-d const ovenDataBindings = parseOvenDataBindings(args.get("oven-data") ?? ""); const writeToken = randomBytes(24).toString("hex"); const repoMapCache = new Map(); -let differentialTestingDataCache = null; +const ovenHandlerCaches = new Map(); const REPO_MAP_CACHE_MS = 2_000; function cachedRepoMap(repo) { @@ -129,207 +124,6 @@ function cachedRepoMap(repo) { return promise; } -function differentialTestingIndexCache(path) { - const readPath = resolve(realpathSync(dirname(path)), basename(path)); - const stat = safeStat(readPath); - if (!stat?.isFile()) throw new Error("configured Differential Testing data is missing"); - const signature = `${readPath}\0${stat.ino}\0${stat.size}\0${stat.mtimeMs}`; - if (differentialTestingDataCache?.signature === signature) return differentialTestingDataCache; - const source = readTextFileWithLimit(readPath, maxOvenDataBytes, "Oven differential-testing data"); - const document = JSON.parse(source); - if (isDifferentialTestingBundle(document)) { - const bundle = readDifferentialTestingBundleManifest(readPath, { maxDocumentBytes: maxOvenDataBytes }); - const emptyPayload = bundle.selectedScenarioId === null ? bundle.manifest.emptyData : null; - const emptySource = emptyPayload ? JSON.stringify(emptyPayload) : ""; - differentialTestingDataCache = { - kind: "bundle", - signature, - readPath: bundle.readPath, - source: emptySource, - sourceBytes: Buffer.byteLength(emptySource), - etag: `W/\"dtb-${bundle.sha256}\"`, - selectedScenarioId: bundle.selectedScenarioId, - scenarios: structuredClone(bundle.scenarios), - bundle, - scenarioDocuments: new Map(), - scenarioResponses: new Map(), - responseExtras: emptyPayload ? { - transport: { - schema: DIFFERENTIAL_TESTING_PAGE_SCHEMA, - bundleSha256: bundle.sha256, - scenarioSha256: null, - }, - fieldPage: null, - frameDeltaMetrics: null, - } : null, - }; - return differentialTestingDataCache; - } - assertDifferentialTestingData(document); - differentialTestingDataCache = { - kind: "legacy", - signature, - readPath, - source, - sourceBytes: stat.size, - etag: `W/\"dt-${stat.ino}-${stat.size}-${Math.trunc(stat.mtimeMs)}\"`, - selectedScenarioId: document.scenarioCatalog.selectedScenarioId, - scenarios: structuredClone(document.scenarioCatalog.scenarios), - scenarioResponses: new Map(), - }; - return differentialTestingDataCache; -} - -function differentialTestingQueryValue(searchParams, name) { - const values = searchParams.getAll(name); - if (values.length > 1) throw Object.assign(new Error(`${name} must be supplied at most once`), { status: 400 }); - return values[0] ?? null; -} - -function differentialTestingIntegerQuery(searchParams, name, fallback) { - const value = differentialTestingQueryValue(searchParams, name); - if (value === null) return fallback; - if (!/^(?:0|[1-9]\d*)$/u.test(value)) { - throw Object.assign(new Error(`${name} must be a non-negative integer`), { status: 400 }); - } - const number = Number(value); - if (!Number.isSafeInteger(number)) throw Object.assign(new Error(`${name} must be a safe integer`), { status: 400 }); - return number; -} - -function differentialTestingBundleScenario(index, scenarioId) { - const cached = index.scenarioDocuments.get(scenarioId); - if (cached) return cached; - const scenario = readDifferentialTestingBundleScenario(index.bundle, scenarioId, { - maxDocumentBytes: maxOvenDataBytes, - }); - scenario.source = JSON.stringify(scenario.data); - scenario.sourceBytes = Buffer.byteLength(scenario.source); - index.scenarioDocuments.set(scenarioId, scenario); - return scenario; -} - -function differentialTestingBundleResponse(index, scenarioId, searchParams) { - const search = differentialTestingQueryValue(searchParams, "search") ?? ""; - const filter = differentialTestingQueryValue(searchParams, "filter") ?? "all"; - const scenario = differentialTestingBundleScenario(index, scenarioId); - const sort = differentialTestingQueryValue(searchParams, "sort") - ?? (scenario.data.telemetry?.status === "comparable" ? "changed" : "default"); - const page = differentialTestingIntegerQuery(searchParams, "page", 0); - const pageSize = differentialTestingIntegerQuery(searchParams, "pageSize", 25); - const fieldPage = queryDifferentialTestingFieldPage(scenario, { search, filter, sort, page, pageSize }); - const queryKey = new URLSearchParams({ - search: fieldPage.search, - filter: fieldPage.filter, - sort: fieldPage.sort, - page: String(fieldPage.page), - pageSize: String(fieldPage.pageSize), - }).toString(); - const etagDigest = createHash("sha256") - .update(index.bundle.sha256) - .update("\0") - .update(scenario.scenarioSha256) - .update("\0") - .update(queryKey) - .digest("hex"); - const result = { - signature: `${index.signature}\0${scenario.scenarioSha256}\0${queryKey}`, - readPath: index.readPath, - source: scenario.source, - sourceBytes: scenario.sourceBytes, - etag: `W/\"dtb-${etagDigest}\"`, - selectedScenarioId: scenarioId, - responseExtras: { - transport: { - schema: DIFFERENTIAL_TESTING_PAGE_SCHEMA, - bundleSha256: index.bundle.sha256, - scenarioSha256: scenario.scenarioSha256, - }, - fieldPage, - frameDeltaMetrics: scenario.frameDeltaMetrics, - }, - }; - return result; -} - -function differentialTestingScenarioCache(index, scenarioId) { - const cached = index.scenarioResponses.get(scenarioId); - const scenariosDir = resolve(dirname(index.readPath), "scenarios"); - const scenarioPath = resolve(scenariosDir, `${scenarioId}.json`); - const scenarioRelativePath = relative(scenariosDir, scenarioPath); - if (!scenarioRelativePath || scenarioRelativePath.startsWith("..") || resolve(scenariosDir, scenarioRelativePath) !== scenarioPath) { - throw Object.assign(new Error("scenario path escapes the published bundle"), { status: 400 }); - } - const stat = safeStat(scenarioPath); - if (!stat?.isFile()) throw Object.assign(new Error(`published data for scenario ${scenarioId} is missing`), { status: 404 }); - 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, - maxOvenDataBytes, - `Differential Testing scenario ${scenarioId}`, - )); - assertDifferentialTestingData(sourcePayload); - if (sourcePayload.scenarioCatalog.selectedScenarioId !== scenarioId) { - throw Object.assign(new Error(`scenario file ${scenarioId} selects ${sourcePayload.scenarioCatalog.selectedScenarioId}`), { status: 422 }); - } - const indexScenario = index.scenarios.find((scenario) => scenario.id === scenarioId); - const payloadScenario = sourcePayload.scenarioCatalog.scenarios.find((scenario) => scenario.id === scenarioId); - if (JSON.stringify(payloadScenario) !== JSON.stringify(indexScenario)) { - 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); - const source = JSON.stringify(payload); - const result = { - signature, - readPath: scenarioPath, - source, - sourceBytes: Buffer.byteLength(source), - etag: `W/\"dt-${stat.ino}-${stat.size}-${Math.trunc(stat.mtimeMs)}-${index.etag.slice(3, -1)}\"`, - selectedScenarioId: scenarioId, - }; - index.scenarioResponses.set(scenarioId, result); - return result; -} - -function sendDifferentialTestingData(req, res, cached) { - if (req.headers["if-none-match"] === cached.etag) { - res.writeHead(304, { etag: cached.etag, "cache-control": "no-store" }); - res.end(); - return; - } - const prefix = `${JSON.stringify({ - ovenId: "differential-testing", - path: cached.readPath, - scenarioId: cached.selectedScenarioId, - ...(cached.responseExtras || {}), - }).slice(0, -1)},\"payload\":`; - const suffix = "}"; - res.writeHead(200, { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store", - etag: cached.etag, - "content-length": Buffer.byteLength(prefix) + cached.sourceBytes + Buffer.byteLength(suffix), - }); - res.write(prefix); - res.write(cached.source); - res.end(suffix); -} - -function warmDifferentialTestingData() { - const path = ovenDataBindings.get("differential-testing"); - if (!path) return; - try { - differentialTestingIndexCache(path); - } catch { - // The request path reports validation errors; background warming remains silent. - } -} - function positiveInteger(value, name) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { @@ -449,48 +243,11 @@ function discoverBurnlists() { return burnlistPaths().map((path) => summaryForPlan(path, maxPlanBytes)); } -function checklistDashboardEntries() { - return discoverBurnlists().map((entry) => ({ - ...entry, - ovenId: "checklist", - ovenName: "Checklist", - href: `/${encodeURIComponent(entry.repo)}/${encodeURIComponent(entry.id)}`, - progressLabel: `${entry.done}/${entry.total} done`, - })); -} - -function differentialTestingDashboardEntries() { - const path = ovenDataBindings.get("differential-testing"); - if (!path) return []; - const index = differentialTestingIndexCache(path); - const matchedRepo = discoveredRepos() - .filter((entry) => index.readPath === entry.root || index.readPath.startsWith(`${entry.root}/`)) - .sort((left, right) => right.root.length - left.root.length)[0] ?? null; - const repo = matchedRepo?.name ?? "differential-testing"; - return index.scenarios.map((scenario) => ({ - id: scenario.id, - repo, - repoRoot: matchedRepo?.root ?? null, - title: scenario.label, - 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)}`, - progressLabel: `${scenario.frameCount} frames`, - })); -} - function dashboardEntries() { - return [...checklistDashboardEntries(), ...differentialTestingDashboardEntries()] + return discoverOvens().flatMap((oven) => { + const handler = getOvenHandler(oven.id) ?? genericJsonHandler; + return handler.dashboardEntries?.(ovenHandlerContext({ oven })) ?? []; + }) .map((entry) => { let key = null; try { @@ -503,6 +260,24 @@ function dashboardEntries() { .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))); } +function ovenHandlerContext({ id, oven, req, res, url, bindingPath } = {}) { + const cacheId = id ?? oven?.id; + if (cacheId && !ovenHandlerCaches.has(cacheId)) ovenHandlerCaches.set(cacheId, new Map()); + return { + id: cacheId, + oven, + req, + res, + url, + bindingPath, + cache: cacheId ? ovenHandlerCaches.get(cacheId) : new Map(), + ovenDataBindings, + maxOvenDataBytes, + discoverBurnlists, + discoveredRepos, + }; +} + function projectsSnapshot() { const home = os.homedir(); const hasScanRootOverride = Boolean(args.get("scan-root")); @@ -1146,33 +921,16 @@ const server = createServer(async (req, res) => { if (ovenDataRoute) { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); const id = ovenDataRoute[1]; - const path = ovenDataBindings.get(id); - if (!path) return json(res, 404, { error: `no data binding configured for Oven ${id}` }); + const oven = discoverOvens().find((entry) => entry.id === id); + const bindingPath = ovenDataBindings.get(id); + if (!oven && !bindingPath) { + return json(res, 404, { validated: false, error: `no data binding configured for Oven ${id}` }); + } + if (!bindingPath) return json(res, 404, { error: `no data binding configured for Oven ${id}` }); try { - if (id === "differential-testing") { - const index = differentialTestingIndexCache(path); - const requestedScenarioIds = url.searchParams.getAll("scenario"); - if (requestedScenarioIds.length > 1) return json(res, 400, { error: "scenario must be supplied at most once" }); - const requestedScenarioId = requestedScenarioIds[0] ?? ""; - if (!requestedScenarioId || requestedScenarioId === index.selectedScenarioId) { - const selected = index.kind === "bundle" && index.selectedScenarioId - ? differentialTestingBundleResponse(index, index.selectedScenarioId, url.searchParams) - : index; - sendDifferentialTestingData(req, res, selected); - return; - } - if (!/^[a-f0-9]{16}$/u.test(requestedScenarioId)) return json(res, 400, { error: "scenario must be a lowercase 16-character hexadecimal id" }); - if (!index.scenarios.some((scenario) => scenario.id === requestedScenarioId)) return json(res, 404, { error: `scenario ${requestedScenarioId} is not in the published catalog` }); - const selected = index.kind === "bundle" - ? differentialTestingBundleResponse(index, requestedScenarioId, url.searchParams) - : differentialTestingScenarioCache(index, requestedScenarioId); - sendDifferentialTestingData(req, res, selected); - return; - } - const readPath = path; - if (!safeStat(readPath)?.isFile()) return json(res, 404, { error: `configured data for Oven ${id} is missing` }); - const indexPayload = JSON.parse(readTextFileWithLimit(readPath, maxOvenDataBytes, `Oven ${id} data`)); - json(res, 200, { ovenId: id, path: readPath, payload: indexPayload }); + const handler = getOvenHandler(id) ?? genericJsonHandler; + const response = handler.serveData?.(ovenHandlerContext({ id, oven, req, res, url, bindingPath })); + if (response !== undefined) json(res, 200, response); } catch (error) { json(res, Number.isInteger(error.status) ? error.status : 422, { error: error instanceof SyntaxError ? `Oven ${id} data is not valid JSON: ${error.message}` : error.message, @@ -1212,7 +970,9 @@ const server = createServer(async (req, res) => { json(res, 200, { run }); return; } - if (["/", "/index.html", "/ovens/new", "/ovens/differential-testing/view", "/runs/new"].includes(url.pathname) || routeSelection(url)) { + const ovenViewRoute = url.pathname.match(/^\/ovens\/([a-z0-9]+(?:-[a-z0-9]+)*)\/view$/u); + const isKnownOvenView = Boolean(ovenViewRoute && discoverOvens().some((oven) => oven.id === ovenViewRoute[1])); + if (["/", "/index.html", "/ovens/new", "/runs/new"].includes(url.pathname) || isKnownOvenView || routeSelection(url)) { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); serveDashboardShell(res); return; @@ -1253,10 +1013,15 @@ function listen(port) { }); } -if (!reportMode && ovenDataBindings.has("differential-testing")) { - warmDifferentialTestingData(); - const differentialTestingWarmTimer = setInterval(warmDifferentialTestingData, 1_000); - differentialTestingWarmTimer.unref(); +if (!reportMode) { + for (const handler of listOvenHandlers()) { + if (typeof handler.warm !== "function" || !handler.warmIntervalMs) continue; + if (!handler.id || !ovenDataBindings.has(handler.id)) continue; + const warm = () => handler.warm(ovenHandlerContext({ id: handler.id })); + warm(); + const warmTimer = setInterval(warm, handler.warmIntervalMs); + warmTimer.unref(); + } } listen(initialPort); diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index ed2e72d..10a4e93 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -5,6 +5,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { buildPayload } from "../../ovens/differential-testing/example/adapter.mjs"; import test from "node:test"; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); @@ -75,7 +76,39 @@ test("/api/burnlists lists discovered Burnlists across the observer set", { time }); }); -async function withServer({ withBurnlist, burnlists, scanRoots }, callback) { +test("Oven data uses registered and generic handlers while unknown ids are unvalidated", { timeout: 20_000 }, async () => { + const differentialTestingPayload = buildPayload( + { captureId: "reference-fixture", generatedAt: "2026-01-01T12:00:00.000Z", fields: [], samples: [] }, + { captureId: "candidate-fixture", generatedAt: "2026-01-01T12:00:00.000Z", samples: [] }, + ); + await withServer({ + withBurnlist: true, + ovenData: [ + { id: "checklist", payload: { source: "generic" } }, + { id: "differential-testing", payload: differentialTestingPayload }, + ], + }, async ({ baseUrl }) => { + const checklist = await httpGet(baseUrl, "/api/oven-data/checklist"); + assert.equal(checklist.status, 200); + assert.deepEqual(JSON.parse(checklist.body).payload, { source: "generic" }); + + // The empty fixture has no scenarios, so the base document is served (selectedScenarioId null). + const differentialTesting = await httpGet(baseUrl, "/api/oven-data/differential-testing"); + assert.equal(differentialTesting.status, 200); + assert.equal(JSON.parse(differentialTesting.body).scenarioId, null); + + const unknown = await httpGet(baseUrl, "/api/oven-data/not-an-oven"); + assert.equal(unknown.status, 404); + assert.equal(JSON.parse(unknown.body).validated, false); + + const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; + assert.equal(entries.some((entry) => entry.ovenId === "checklist"), true); + // The empty DT payload publishes no scenarios, so its handler contributes no dashboard rows. + assert.equal(entries.some((entry) => entry.ovenId === "differential-testing"), false); + }); +}); + +async function withServer({ withBurnlist, burnlists, ovenData = [], scanRoots }, callback) { const fixtureRoot = await mkdtemp(join(tmpdir(), "burnlist-dashboard-routes-")); const homeRoot = join(fixtureRoot, "home"); const fixtures = burnlists ?? (withBurnlist ? [{}] : []); @@ -94,13 +127,19 @@ async function withServer({ withBurnlist, burnlists, scanRoots }, callback) { writeFile(join(dirname(planPath), "goal.md"), "# Fixture Goal\n\n## Goal\n\nRoute behavior fixture.\n"), ]); })); + await Promise.all(ovenData.map(({ id, payload }) => writeFile( + join(fixtureRoot, `${id}.json`), + JSON.stringify(payload), + ))); const port = await availablePort(); + const ovenDataBindings = ovenData.map(({ id }) => `${id}=${join(fixtureRoot, `${id}.json`)}`).join(","); child = spawn(process.execPath, [ serverPath, "--port", String(port), "--auto-port", "--scan-root", rootPaths.map((path) => join(fixtureRoot, path)).join(","), "--state-dir", join(fixtureRoot, "state"), + ...(ovenDataBindings ? ["--oven-data", ovenDataBindings] : []), ], { cwd: fixtureRoot, env: { ...process.env, HOME: homeRoot }, From dfaaa9df968185685204cbc88187568fd85bd61e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 00:21:16 +0200 Subject: [PATCH 02/30] feat: persist oven data bindings per repo --- bin/burnlist.mjs | 2 +- scripts/verify.mjs | 2 + src/cli/oven-cli.mjs | 41 +++++++ src/cli/oven-cli.test.mjs | 39 +++++++ src/server/burnlist-dashboard-server.mjs | 33 ++++-- src/server/oven-bindings.mjs | 137 +++++++++++++++++++++++ src/server/oven-bindings.test.mjs | 86 ++++++++++++++ 7 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 src/cli/oven-cli.test.mjs create mode 100644 src/server/oven-bindings.mjs create mode 100644 src/server/oven-bindings.test.mjs diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 22da17c..66910e4 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -120,7 +120,7 @@ Usage: burnlist differential-testing validate-bundle burnlist differential-testing schema burnlist differential-testing sdk - burnlist oven ... + burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] burnlist ready [--repo ] diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 8ff5bb5..66a1536 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -433,7 +433,9 @@ run(process.execPath, [ "src/cli/lifecycle-cli.test.mjs", "src/cli/lifecycle-moves.test.mjs", "src/cli/registry-cli.test.mjs", + "src/cli/oven-cli.test.mjs", "src/cli/umbrella.test.mjs", + "src/server/oven-bindings.test.mjs", "src/server/registry.test.mjs", "src/server/repo-map.test.mjs", "src/server/repo-state.test.mjs", diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index c353cd0..161165f 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -12,7 +12,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, s import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenDetail, normalizeOvenPackage, ovenId } from "../ovens/oven-contract.mjs"; +import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; +import { resolveUmbrella } from "./umbrella.mjs"; const MAX_INSTRUCTION_BYTES = 65536; const MAX_DETAIL_BYTES = 131072; @@ -46,6 +48,11 @@ function fail(message) { process.exit(1); } +function bindingRepo() { + if (flags.get("repo") === "true") fail("--repo requires a path."); + return flags.has("repo") ? resolve(launchCwd, flags.get("repo")) : resolveUmbrella(launchCwd); +} + // ── storage locations (mirror the dashboard server) ────────────────────────── const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const builtInOvensDir = resolve(packageRoot, "ovens"); @@ -247,6 +254,9 @@ const HELP = `burnlist oven — author and inspect Ovens Usage: burnlist oven list [--json] burnlist oven view [--json] [--cell-width ] [--cell-height ] + burnlist oven bind [--repo ] + burnlist oven unbind [--repo ] + burnlist oven bindings [--repo ] burnlist oven create --instructions --detail [--name ] burnlist oven create --dir (reads instructions.md + detail.json) burnlist oven create --package (JSON: {name?, instructions, detail}) @@ -258,6 +268,7 @@ Options: --detail

detail.json file, or - for stdin. --dir

Directory containing instructions.md and detail.json. --package

JSON package file, or - for stdin. + --repo

Repository whose local Oven bindings to use. --ovens-dir

Custom Oven storage (default .local/burnlist/ovens). --force On create, replace an existing custom Oven. --json Machine-readable output for list/view. @@ -310,6 +321,36 @@ try { process.exit(0); } + if (subcommand === "bind") { + const [id, logicalPath] = positionals; + if (!id || logicalPath === undefined) fail("Usage: burnlist oven bind [--repo ]"); + const repoRoot = bindingRepo(); + const result = writeBinding(repoRoot, id, logicalPath, new Date().toISOString()); + console.log(`Bound Oven ${ovenId(id)} to ${logicalPath}\nStore: ${result.path}`); + process.exit(0); + } + + if (subcommand === "unbind") { + const id = positionals[0]; + if (!id) fail("Usage: burnlist oven unbind [--repo ]"); + const repoRoot = bindingRepo(); + if (removeBinding(repoRoot, id)) console.log(`Unbound Oven ${ovenId(id)} from ${bindingStorePath(repoRoot)}`); + else console.log(`No binding exists for Oven ${ovenId(id)} in ${bindingStorePath(repoRoot)}.`); + process.exit(0); + } + + if (subcommand === "bindings") { + const repoRoot = bindingRepo(); + const store = readBindingStore(repoRoot); + const entries = Object.entries(store.bindings).sort(([left], [right]) => left.localeCompare(right)); + if (entries.length === 0) console.log(`No Oven bindings in ${bindingStorePath(repoRoot)}.`); + else { + console.log(`Oven bindings: ${bindingStorePath(repoRoot)}`); + for (const [id, binding] of entries) console.log(`${id} ${binding.path} ${binding.boundAt}`); + } + process.exit(0); + } + if (subcommand === "create" || subcommand === "update") { const pkg = resolvePackageInput(); assertCustomTarget(pkg.id, subcommand); diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs new file mode 100644 index 0000000..8491d16 --- /dev/null +++ b/src/cli/oven-cli.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +const repoRoot = resolve(new URL("../..", import.meta.url).pathname); +const binPath = join(repoRoot, "bin", "burnlist.mjs"); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "burnlist-oven-cli-")); + const repo = join(root, "repo"); + mkdirSync(repo); + return { repo, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +function run(context, ...args) { + return execFileSync(process.execPath, [binPath, ...args], { cwd: context.repo, encoding: "utf8" }); +} + +test("oven bind, bindings, and unbind persist a logical repo-local binding", () => { + const context = fixture(); + const logicalPath = "../generated/current.json"; + const storePath = join(context.repo, ".local", "burnlist", "bindings.json"); + try { + const bound = run(context, "oven", "bind", "sample-oven", logicalPath, "--repo", context.repo); + assert.match(bound, /Bound Oven sample-oven to \.\.\/generated\/current\.json/u); + assert.match(bound, new RegExp(`Store: ${storePath}`)); + const store = JSON.parse(readFileSync(storePath, "utf8")); + assert.equal(store.schemaVersion, 1); + assert.equal(store.bindings["sample-oven"].path, logicalPath); + assert.match(store.bindings["sample-oven"].boundAt, /^\d{4}-\d{2}-\d{2}T/u); + assert.match(run(context, "oven", "bindings", "--repo", context.repo), /sample-oven \.\.\/generated\/current\.json/u); + assert.match(run(context, "oven", "unbind", "sample-oven", "--repo", context.repo), /Unbound Oven sample-oven/u); + assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")), { schemaVersion: 1, bindings: {} }); + assert.match(run(context, "oven", "unbind", "sample-oven", "--repo", context.repo), /No binding exists for Oven sample-oven/u); + } finally { context.cleanup(); } +}); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 46a44fe..cc393a6 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -15,6 +15,7 @@ import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import os from "node:os"; import { mutatorRepoRoots, observerRepoRoots } from "./discovery.mjs"; +import { effectiveBindings } from "./oven-bindings.mjs"; import { classifyRoots, readRegistry, repoKey } from "./registry.mjs"; import { buildProjectsSnapshot } from "./projects.mjs"; import { containedJoin, repoStateDir, withRepoStateLock } from "./repo-state.mjs"; @@ -104,7 +105,7 @@ const dashboardIndexPath = resolve(dashboardDistDir, "index.html"); const builtInOvensDir = resolve(packageRoot, "ovens"); const customOvensDir = resolve(launchCwd, args.get("ovens-dir") ?? ".local/burnlist/ovens"); const legacyRunsDir = args.has("runs-dir") ? resolve(launchCwd, args.get("runs-dir")) : null; -const ovenDataBindings = parseOvenDataBindings(args.get("oven-data") ?? ""); +const ovenDataOverrides = parseOvenDataBindings(args.get("oven-data") ?? ""); const writeToken = randomBytes(24).toString("hex"); const repoMapCache = new Map(); const ovenHandlerCaches = new Map(); @@ -219,6 +220,11 @@ function candidateRepoRoots() { return observerRepoRoots({ cwd: launchCwd, home: os.homedir(), scanRoot: args.get("scan-root") }); } +function resolvedOvenDataBindings() { + return new Map([...effectiveBindings({ repoRoots: candidateRepoRoots(), override: ovenDataOverrides })] + .map(([id, binding]) => [id, binding.path])); +} + function burnlistPathsFor(repoRoots) { const paths = []; for (const repoRoot of repoRoots) { @@ -243,10 +249,10 @@ function discoverBurnlists() { return burnlistPaths().map((path) => summaryForPlan(path, maxPlanBytes)); } -function dashboardEntries() { +function dashboardEntries(ovenDataBindings = resolvedOvenDataBindings()) { return discoverOvens().flatMap((oven) => { const handler = getOvenHandler(oven.id) ?? genericJsonHandler; - return handler.dashboardEntries?.(ovenHandlerContext({ oven })) ?? []; + return handler.dashboardEntries?.(ovenHandlerContext({ oven, ovenDataBindings })) ?? []; }) .map((entry) => { let key = null; @@ -260,7 +266,7 @@ function dashboardEntries() { .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))); } -function ovenHandlerContext({ id, oven, req, res, url, bindingPath } = {}) { +function ovenHandlerContext({ id, oven, req, res, url, bindingPath, ovenDataBindings = resolvedOvenDataBindings() } = {}) { const cacheId = id ?? oven?.id; if (cacheId && !ovenHandlerCaches.has(cacheId)) ovenHandlerCaches.set(cacheId, new Map()); return { @@ -278,7 +284,7 @@ function ovenHandlerContext({ id, oven, req, res, url, bindingPath } = {}) { }; } -function projectsSnapshot() { +function projectsSnapshot(ovenDataBindings = resolvedOvenDataBindings()) { const home = os.homedir(); const hasScanRootOverride = Boolean(args.get("scan-root")); let registeredRoots = []; @@ -323,7 +329,7 @@ function projectsSnapshot() { observerRoots, registeredRoots, health, - entries: dashboardEntries(), + entries: dashboardEntries(ovenDataBindings), repoKey, realpath: realpathSync, }); @@ -863,6 +869,7 @@ const server = createServer(async (req, res) => { try { const url = new URL(req.url ?? "/", `http://${host}`); const method = req.method ?? "GET"; + const ovenDataBindings = resolvedOvenDataBindings(); const dashboardAsset = dashboardAssetPath(url.pathname); if (dashboardAsset) { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); @@ -874,12 +881,12 @@ const server = createServer(async (req, res) => { } if (url.pathname === "/api/projects") { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); - json(res, 200, projectsSnapshot()); + json(res, 200, projectsSnapshot(ovenDataBindings)); return; } if (url.pathname === "/api/burnlists") { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); - json(res, 200, { generatedAt: new Date().toISOString(), burnlists: dashboardEntries() }); + json(res, 200, { generatedAt: new Date().toISOString(), burnlists: dashboardEntries(ovenDataBindings) }); return; } if (url.pathname === "/api/progress") { @@ -929,7 +936,7 @@ const server = createServer(async (req, res) => { if (!bindingPath) return json(res, 404, { error: `no data binding configured for Oven ${id}` }); try { const handler = getOvenHandler(id) ?? genericJsonHandler; - const response = handler.serveData?.(ovenHandlerContext({ id, oven, req, res, url, bindingPath })); + const response = handler.serveData?.(ovenHandlerContext({ id, oven, req, res, url, bindingPath, ovenDataBindings })); if (response !== undefined) json(res, 200, response); } catch (error) { json(res, Number.isInteger(error.status) ? error.status : 422, { @@ -1016,8 +1023,12 @@ function listen(port) { if (!reportMode) { for (const handler of listOvenHandlers()) { if (typeof handler.warm !== "function" || !handler.warmIntervalMs) continue; - if (!handler.id || !ovenDataBindings.has(handler.id)) continue; - const warm = () => handler.warm(ovenHandlerContext({ id: handler.id })); + if (!handler.id) continue; + const warm = () => { + const ovenDataBindings = resolvedOvenDataBindings(); + if (!ovenDataBindings.has(handler.id)) return; + handler.warm(ovenHandlerContext({ id: handler.id, ovenDataBindings })); + }; warm(); const warmTimer = setInterval(warm, handler.warmIntervalMs); warmTimer.unref(); diff --git a/src/server/oven-bindings.mjs b/src/server/oven-bindings.mjs new file mode 100644 index 0000000..172fd00 --- /dev/null +++ b/src/server/oven-bindings.mjs @@ -0,0 +1,137 @@ +// Binding precedence: persisted repo-local bindings merge by smallest repo root; +// explicit --oven-data overrides always win. Persisted relative paths resolve per read. +import { randomBytes } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { ovenId } from "../ovens/oven-contract.mjs"; +import { withRepoStateLock } from "./repo-state.mjs"; + +export const BINDING_SCHEMA_VERSION = 1; + +export function bindingStoreDir(repoRoot) { + return join(repoRoot, ".local", "burnlist"); +} + +export function bindingStorePath(repoRoot) { + return join(bindingStoreDir(repoRoot), "bindings.json"); +} + +function emptyStore() { + return { schemaVersion: BINDING_SCHEMA_VERSION, bindings: {} }; +} + +function isPlainObject(value) { + return value && typeof value === "object" && !Array.isArray(value); +} + +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 validateStore(store) { + if (!isPlainObject(store) || store.schemaVersion !== BINDING_SCHEMA_VERSION || !isPlainObject(store.bindings)) return false; + return Object.entries(store.bindings).every(([id, binding]) => { + try { + ovenId(id); + } catch { + return false; + } + return isPlainObject(binding) + && typeof binding.path === "string" + && binding.path.length > 0 + && isTimestamp(binding.boundAt); + }); +} + +function corruptStore(path) { + console.warn(`Ignoring corrupt Oven binding store: ${path}`); + return emptyStore(); +} + +export function readBindingStore(repoRoot) { + const path = bindingStorePath(repoRoot); + let text; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") return emptyStore(); + throw error; + } + try { + const store = JSON.parse(text); + return validateStore(store) ? store : corruptStore(path); + } catch { + return corruptStore(path); + } +} + +function writeStore(repoRoot, store) { + const dir = bindingStoreDir(repoRoot); + const path = bindingStorePath(repoRoot); + const temporary = join(dir, `.bindings.json.${randomBytes(12).toString("hex")}`); + mkdirSync(dir, { recursive: true }); + try { + writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`); + renameSync(temporary, path); + } catch (error) { + rmSync(temporary, { force: true }); + throw error; + } +} + +export function writeBinding(repoRoot, id, logicalPath, boundAt) { + 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 = readBindingStore(repoRoot); + store.bindings[safeId] = { path: logicalPath, boundAt }; + writeStore(repoRoot, store); + return { path: bindingStorePath(repoRoot), binding: store.bindings[safeId] }; + }); +} + +export function removeBinding(repoRoot, id) { + const safeId = ovenId(id); + return withRepoStateLock(repoRoot, () => { + const store = readBindingStore(repoRoot); + if (!Object.hasOwn(store.bindings, safeId)) return false; + delete store.bindings[safeId]; + writeStore(repoRoot, store); + return true; + }); +} + +const cachedStores = new Map(); + +function mtimeFor(path, stat) { + try { + return stat(path).mtimeMs; + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +function cachedStore(repoRoot, stat) { + const path = bindingStorePath(repoRoot); + const mtimeMs = mtimeFor(path, stat); + const cached = cachedStores.get(path); + if (cached && cached.mtimeMs === mtimeMs) return cached.store; + const store = readBindingStore(repoRoot); + cachedStores.set(path, { mtimeMs, store }); + return store; +} + +export function effectiveBindings({ repoRoots = [], override = new Map(), statFn = statSync } = {}) { + const bindings = new Map(); + for (const repoRoot of [...new Set(repoRoots)].sort((left, right) => left.localeCompare(right))) { + for (const [id, binding] of Object.entries(cachedStore(repoRoot, statFn).bindings)) { + if (!bindings.has(id)) bindings.set(id, { path: resolve(repoRoot, binding.path), repoRoot }); + } + } + for (const [id, path] of override) bindings.set(id, { path, repoRoot: null }); + return bindings; +} diff --git a/src/server/oven-bindings.test.mjs b/src/server/oven-bindings.test.mjs new file mode 100644 index 0000000..9e7d9c1 --- /dev/null +++ b/src/server/oven-bindings.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { + bindingStorePath, + effectiveBindings, + readBindingStore, + removeBinding, + writeBinding, +} from "./oven-bindings.mjs"; + +const BOUND_AT = "2026-07-14T12:00:00.000Z"; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "burnlist-oven-bindings-")); + return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +function quietly(fn) { + const warn = console.warn; + console.warn = () => {}; + try { + return fn(); + } finally { + console.warn = warn; + } +} + +test("binding stores round-trip logical paths atomically", () => { + const { root, cleanup } = fixture(); + try { + const result = writeBinding(root, "sample-oven", "../data/current.json", BOUND_AT); + assert.equal(result.path, bindingStorePath(root)); + assert.deepEqual(readBindingStore(root), { + schemaVersion: 1, + bindings: { "sample-oven": { path: "../data/current.json", boundAt: BOUND_AT } }, + }); + assert.equal(removeBinding(root, "sample-oven"), true); + assert.equal(removeBinding(root, "sample-oven"), false); + } finally { cleanup(); } +}); + +test("missing, corrupt, and obsolete binding stores fail closed", () => { + const { root, cleanup } = fixture(); + try { + assert.deepEqual(readBindingStore(root), { schemaVersion: 1, bindings: {} }); + mkdirSync(join(root, ".local", "burnlist"), { recursive: true }); + writeFileSync(bindingStorePath(root), "not json"); + assert.deepEqual(quietly(() => readBindingStore(root)), { schemaVersion: 1, bindings: {} }); + writeFileSync(bindingStorePath(root), '{"schemaVersion":2,"bindings":{}}'); + assert.deepEqual(quietly(() => readBindingStore(root)), { schemaVersion: 1, bindings: {} }); + } finally { cleanup(); } +}); + +test("effective bindings re-read a store after its mtime changes", () => { + const { root, cleanup } = fixture(); + try { + writeBinding(root, "sample-oven", "first.json", BOUND_AT); + assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven").path, resolve(root, "first.json")); + writeBinding(root, "sample-oven", "second.json", "2026-07-14T12:01:00.000Z"); + const changed = new Date("2026-07-14T12:02:00.000Z"); + utimesSync(bindingStorePath(root), changed, changed); + assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven").path, resolve(root, "second.json")); + } finally { cleanup(); } +}); + +test("effective bindings select the smallest repository root and let overrides win", () => { + const { root, cleanup } = fixture(); + const first = join(root, "a-repo"); + const second = join(root, "b-repo"); + try { + mkdirSync(first); + mkdirSync(second); + writeBinding(first, "sample-oven", "first.json", BOUND_AT); + writeBinding(second, "sample-oven", "second.json", BOUND_AT); + const persisted = effectiveBindings({ repoRoots: [second, first] }).get("sample-oven"); + assert.deepEqual(persisted, { path: resolve(first, "first.json"), repoRoot: first }); + const overridden = effectiveBindings({ + repoRoots: [first, second], + override: new Map([["sample-oven", "/temporary/override.json"]]), + }).get("sample-oven"); + assert.deepEqual(overridden, { path: "/temporary/override.json", repoRoot: null }); + } finally { cleanup(); } +}); From eaa26ab6b965ffab1c85005517ee01c951ff945a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 00:31:48 +0200 Subject: [PATCH 03/30] feat: pin oven revision in runs with lineage sidecar --- scripts/verify.mjs | 4 +- src/cli/oven-cli.mjs | 32 ++++- src/cli/oven-cli.test.mjs | 52 +++++++- src/ovens/oven-contract.mjs | 33 +++++ src/ovens/oven-contract.test.mjs | 66 ++++++++++ src/server/burnlist-dashboard-server.mjs | 39 +++++- src/server/dashboard-routes.test.mjs | 150 ++++++++++++++++++++++- 7 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 src/ovens/oven-contract.test.mjs diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 66a1536..366e2bd 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -338,7 +338,8 @@ assertSourceExcludes("src/server/burnlist-dashboard-server.mjs", "builder-hint", assertSourceExcludes("dashboard/src/components/BurnOvens/BurnOvens.tsx", "Drag to place a detail section", "React New Oven still renders the removed skeleton helper text."); assertSourceExcludes("src/server/burnlist-dashboard-server.mjs", "grid-ruler", "Oven detail skeleton still renders grid ruler numbers."); assertSourceExcludes("src/server/burnlist-dashboard-server.mjs", 'class="form-card oven-builder"', "Oven detail skeleton is still wrapped in a card container."); -assertSourceExcludes("src/server/burnlist-dashboard-server.mjs", "ovenRevision", "Burn runs still claim a fixed Oven revision."); +assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", "schemaVersion: 4", "Burn runs do not write manifest schema version 4."); +assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", "ovenRevision: oven.ovenRevision", "Burn runs do not pin the Oven revision."); assertSourceIncludes("dashboard/src/components/BurnOvens/BurnOvens.tsx", 'useState("checklist")', "React Run Burn does not default to Checklist."); assertSourceIncludes("ovens/checklist/instructions.md", "## Active Checklist", "Checklist no longer preserves the Burnlist active queue contract."); assertSourceIncludes("ovens/differential-testing/instructions.md", "fix the capture, adapter, or comparison seam", "Differential Testing is missing source-fix discipline."); @@ -421,6 +422,7 @@ assertPublishablePackage(); run(process.execPath, [ "--test", "src/server/dashboard-routes.test.mjs", + "src/ovens/oven-contract.test.mjs", "src/ovens/oven-registry.test.mjs", "src/server/projects.test.mjs", "src/server/projects-api.test.mjs", diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index 161165f..11905f2 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { normalizeOvenDetail, normalizeOvenPackage, ovenId } from "../ovens/oven-contract.mjs"; +import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; @@ -106,6 +106,17 @@ function readOvenDir(root, id, builtIn) { instructions: readTextFileWithLimit(instructionsPath, MAX_INSTRUCTION_BYTES, "Oven instructions"), detail: JSON.parse(readTextFileWithLimit(detailPath, MAX_DETAIL_BYTES, "Oven detail template")), }); + const lineagePath = join(ovenRoot, "oven.json"); + let forkedFrom; + if (safeStat(lineagePath)?.isFile()) { + try { + forkedFrom = normalizeOvenForkedFrom( + JSON.parse(readTextFileWithLimit(lineagePath, MAX_DETAIL_BYTES, "Oven lineage sidecar")), + ).forkedFrom; + } catch (error) { + throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); + } + } return { id: ovenPackage.id, name: instructionsName(ovenPackage.instructions, safeId), @@ -114,6 +125,8 @@ function readOvenDir(root, id, builtIn) { path: ovenRoot, instructions: ovenPackage.instructions, detail: ovenPackage.detail, + ovenRevision: ovenRevision(ovenPackage), + ...(forkedFrom ? { forkedFrom } : {}), }; } @@ -124,7 +137,8 @@ function ovensIn(root, builtIn) { .map((id) => { try { return readOvenDir(root, id, builtIn); - } catch { + } catch (error) { + if (safeStat(join(root, id, "oven.json"))?.isFile()) throw error; return null; } }) @@ -156,6 +170,7 @@ function printOven(oven) { console.log(`${oven.name} (${oven.id} · ${kind})`); if (oven.description) console.log(oven.description); console.log(`grid: ${oven.detail.columns} cols × ${oven.detail.rows} rows · ${oven.detail.cells.length} sections`); + console.log(`revision: ${oven.ovenRevision}`); console.log(`path: ${oven.path}`); console.log(""); console.log(renderGrid(oven.detail, cellWidth, cellHeight)); @@ -298,8 +313,9 @@ try { oven.builtIn ? "built-in" : "custom", `${oven.detail.columns}×${oven.detail.rows}`, String(oven.detail.cells.length), + oven.ovenRevision, ]); - const header = ["id", "name", "kind", "grid", "sections"]; + const header = ["id", "name", "kind", "grid", "sections", "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)); @@ -314,7 +330,15 @@ try { const oven = findOven(id); if (!oven) fail(`Unknown Oven "${id}". Run \`burnlist oven list\`.`); if (flags.has("json")) { - console.log(JSON.stringify({ id: oven.id, name: oven.name, builtIn: oven.builtIn, instructions: oven.instructions, detail: oven.detail }, null, 2)); + console.log(JSON.stringify({ + id: oven.id, + name: oven.name, + builtIn: oven.builtIn, + instructions: oven.instructions, + detail: oven.detail, + ovenRevision: oven.ovenRevision, + ...(oven.forkedFrom ? { forkedFrom: oven.forkedFrom } : {}), + }, null, 2)); process.exit(0); } printOven(oven); diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index 8491d16..b231cac 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -19,6 +19,35 @@ function run(context, ...args) { return execFileSync(process.execPath, [binPath, ...args], { cwd: context.repo, encoding: "utf8" }); } +function detailFixture() { + return { + version: 1, + columns: 2, + rows: 2, + rowHeight: 48, + cells: [{ + id: "summary", + title: "Summary", + description: "Current status.", + widget: "metric", + source: "/summary", + format: "plain", + column: 1, + row: 1, + columnSpan: 2, + rowSpan: 1, + }], + }; +} + +function writeOven(root, id, ovenJson) { + const ovenRoot = join(root, id); + mkdirSync(ovenRoot, { recursive: true }); + writeFileSync(join(ovenRoot, "instructions.md"), "# Forked Oven\n\nFollow the checklist.\n"); + writeFileSync(join(ovenRoot, "detail.json"), `${JSON.stringify(detailFixture())}\n`); + if (ovenJson !== undefined) writeFileSync(join(ovenRoot, "oven.json"), ovenJson); +} + test("oven bind, bindings, and unbind persist a logical repo-local binding", () => { const context = fixture(); const logicalPath = "../generated/current.json"; @@ -37,3 +66,24 @@ test("oven bind, bindings, and unbind persist a logical repo-local binding", () assert.match(run(context, "oven", "unbind", "sample-oven", "--repo", context.repo), /No binding exists for Oven sample-oven/u); } finally { context.cleanup(); } }); + +test("oven view reads optional fork lineage and rejects malformed sidecars", () => { + const context = fixture(); + const ovensDir = join(context.repo, "ovens"); + try { + writeOven(ovensDir, "forked-oven", JSON.stringify({ + forkedFrom: { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }, + })); + writeOven(ovensDir, "standalone-oven"); + const forked = JSON.parse(run(context, "oven", "view", "forked-oven", "--json", "--ovens-dir", ovensDir)); + assert.deepEqual(forked.forkedFrom, { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }); + const standalone = JSON.parse(run(context, "oven", "view", "standalone-oven", "--json", "--ovens-dir", ovensDir)); + assert.equal(Object.hasOwn(standalone, "forkedFrom"), false); + + writeOven(ovensDir, "broken-oven", "{"); + assert.throws( + () => run(context, "oven", "view", "broken-oven", "--json", "--ovens-dir", ovensDir), + (error) => String(error.stderr).includes("lineage sidecar is invalid"), + ); + } finally { context.cleanup(); } +}); diff --git a/src/ovens/oven-contract.mjs b/src/ovens/oven-contract.mjs index aaa9791..9174476 100644 --- a/src/ovens/oven-contract.mjs +++ b/src/ovens/oven-contract.mjs @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + const ovenWidgets = new Set([ "metric", "progress", @@ -117,3 +119,34 @@ export function normalizeOvenPackage(value) { detail: normalizeOvenDetail(value.detail), }; } + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (plainObject(value)) { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +// Callers pass an Oven package whose instructions and detail have already been +// normalized by normalizeOvenPackage. Only its portable content defines this id. +export function ovenRevision(pkg) { + const instructions = String(pkg.instructions ?? "").replace(/\r\n?/gu, "\n"); + const contents = canonicalJson({ + format: "burnlist-oven-content@1", + instructions, + detail: pkg.detail, + }); + return `o1-sha256:${createHash("sha256").update(contents).digest("hex")}`; +} + +export function normalizeOvenForkedFrom(value) { + assertKnownKeys(value, new Set(["forkedFrom"]), "Oven lineage"); + if (!plainObject(value.forkedFrom)) throw new Error("Oven lineage forkedFrom must be an object."); + assertKnownKeys(value.forkedFrom, new Set(["ovenId", "revision"]), "Oven lineage forkedFrom"); + const revision = boundedText(value.forkedFrom.revision, "Oven lineage revision", 74); + if (!/^o1-sha256:[a-f0-9]{64}$/u.test(revision)) { + throw new Error("Oven lineage revision must be an o1-sha256 digest."); + } + return { forkedFrom: { ovenId: ovenId(value.forkedFrom.ovenId), revision } }; +} diff --git a/src/ovens/oven-contract.test.mjs b/src/ovens/oven-contract.test.mjs new file mode 100644 index 0000000..a6c5435 --- /dev/null +++ b/src/ovens/oven-contract.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeOvenPackage, ovenRevision } from "./oven-contract.mjs"; + +function packageFixture(id = "sample-oven") { + return { + id, + instructions: "# Sample Oven\n\nFollow the checklist.\n", + detail: { + version: 1, + columns: 2, + rows: 2, + rowHeight: 48, + cells: [{ + id: "summary", + title: "Summary", + description: "Current status.", + widget: "metric", + source: "/summary", + format: "plain", + column: 1, + row: 1, + columnSpan: 2, + rowSpan: 1, + }], + }, + }; +} + +function normalizedFixture(id) { + return normalizeOvenPackage(packageFixture(id)); +} + +test("unmodified-fork-shares-revision", () => { + assert.equal(ovenRevision(normalizedFixture("original-oven")), ovenRevision(normalizedFixture("forked-oven"))); +}); + +test("content-change-changes-revision", () => { + const original = normalizedFixture("sample-oven"); + const changedInstructions = { ...original, instructions: "# Sample Oven\n\nUse the revised checklist." }; + const changedTitle = { + ...original, + detail: { ...original.detail, cells: [{ ...original.detail.cells[0], title: "Revised summary" }] }, + }; + const changedVersion = { ...original, detail: { ...original.detail, version: 2 } }; + const revision = ovenRevision(original); + assert.notEqual(ovenRevision(changedInstructions), revision); + assert.notEqual(ovenRevision(changedTitle), revision); + assert.notEqual(ovenRevision(changedVersion), revision); +}); + +test("key-order-invariant", () => { + const original = normalizedFixture("sample-oven"); + const reordered = { + id: "other-oven", + instructions: original.instructions.replaceAll("\n", "\r\n"), + detail: { + cells: original.detail.cells.map((cell) => ({ rowSpan: cell.rowSpan, title: cell.title, id: cell.id, ...cell })), + rowHeight: original.detail.rowHeight, + rows: original.detail.rows, + columns: original.detail.columns, + version: original.detail.version, + }, + }; + assert.equal(ovenRevision(original), ovenRevision(reordered)); +}); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index cc393a6..31c02e9 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -23,8 +23,10 @@ import { assertKnownKeys, boundedText, normalizeOvenDetail, + normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, + ovenRevision, } from "../ovens/oven-contract.mjs"; import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; @@ -358,6 +360,17 @@ function readOven(root, id, builtIn) { instructions: readTextFileWithLimit(instructionsPath, 65536, "Oven instructions"), detail: JSON.parse(readTextFileWithLimit(detailPath, 131072, "Oven detail template")), }); + const lineagePath = join(ovenRoot, "oven.json"); + let forkedFrom; + if (safeStat(lineagePath)?.isFile()) { + try { + forkedFrom = normalizeOvenForkedFrom( + JSON.parse(readTextFileWithLimit(lineagePath, 131072, "Oven lineage sidecar")), + ).forkedFrom; + } catch (error) { + throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); + } + } return { id: ovenPackage.id, name: instructionsName(ovenPackage.instructions, safeId), @@ -365,6 +378,8 @@ function readOven(root, id, builtIn) { builtIn, instructions: ovenPackage.instructions, detail: ovenPackage.detail, + ovenRevision: ovenRevision(ovenPackage), + ...(forkedFrom ? { forkedFrom } : {}), }; } @@ -391,6 +406,8 @@ function ovenSummary(oven) { name: oven.name, description: oven.description, builtIn: oven.builtIn, + ovenRevision: oven.ovenRevision, + ...(oven.forkedFrom ? { forkedFrom: oven.forkedFrom } : {}), detail: { columns: oven.detail.columns, rows: oven.detail.rows, @@ -486,9 +503,10 @@ function createBurnRun(value) { const id = runId(); const createdAt = new Date().toISOString(); const record = { - schemaVersion: 3, + schemaVersion: 4, id, ovenId: selectedOvenId, + ovenRevision: oven.ovenRevision, repoRoot: repo.root, repo: repo.name, title, @@ -526,6 +544,7 @@ function readBurnRun(id) { "schemaVersion", "id", "ovenId", + "ovenRevision", "repoRoot", "repo", "title", @@ -537,7 +556,23 @@ function readBurnRun(id) { "sections", ]), "Burn run"); ovenId(record.ovenId); - return record; + if (![3, 4].includes(record.schemaVersion)) { + throw new Error("Burn run schemaVersion must be 3 or 4."); + } + if (record.schemaVersion === 4) { + const ovenRevisionValue = boundedText(record.ovenRevision, "Burn run ovenRevision", 74); + if (!/^o1-sha256:[a-f0-9]{64}$/u.test(ovenRevisionValue)) { + throw new Error("Burn run ovenRevision must be an o1-sha256 digest."); + } + return record; + } + const runRoot = dirname(path); + const ovenPackage = normalizeOvenPackage({ + id: record.ovenId, + instructions: readTextFileWithLimit(join(runRoot, "instructions.md"), 65536, "Run Oven instructions"), + detail: JSON.parse(readTextFileWithLimit(join(runRoot, "detail.json"), 131072, "Run Oven detail template")), + }); + return { ...record, ovenRevision: ovenRevision(ovenPackage) }; } return null; } diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 10a4e93..e43970a 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { createServer, get } from "node:http"; +import { createServer, get, request } from "node:http"; +import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildPayload } from "../../ovens/differential-testing/example/adapter.mjs"; +import { normalizeOvenPackage, ovenRevision } from "../ovens/oven-contract.mjs"; import test from "node:test"; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); @@ -108,7 +110,68 @@ test("Oven data uses registered and generic handlers while unknown ids are unval }); }); -async function withServer({ withBurnlist, burnlists, ovenData = [], scanRoots }, callback) { +test("Oven discovery exposes optional lineage and rejects malformed sidecars", { timeout: 20_000 }, async () => { + const forkedFrom = { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }; + await withServer({ + ovens: [ + { id: "forked-oven", ovenJson: { forkedFrom } }, + { id: "standalone-oven" }, + ], + }, async ({ baseUrl }) => { + const forked = JSON.parse((await httpGet(baseUrl, "/api/ovens/forked-oven")).body).oven; + const standalone = JSON.parse((await httpGet(baseUrl, "/api/ovens/standalone-oven")).body).oven; + assert.deepEqual(forked.forkedFrom, forkedFrom); + assert.equal(Object.hasOwn(standalone, "forkedFrom"), false); + }); + await withServer({ ovens: [{ id: "broken-oven", ovenJson: "{" }] }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, "/api/ovens"); + assert.equal(response.status, 400); + assert.match(JSON.parse(response.body).error, /lineage sidecar is invalid/u); + }); +}); + +test("Burn runs read legacy v3 revisions and write/read v4 revisions", { timeout: 20_000 }, async () => { + const legacyInstructions = "# Legacy Oven\n\nFollow the checklist.\n"; + const legacyDetail = detailFixture(); + const expectedLegacyRevision = ovenRevision(normalizeOvenPackage({ + id: "legacy-oven", + instructions: legacyInstructions, + detail: legacyDetail, + })); + const legacyRunId = "20260714-120000-a1b2c3"; + const unsupportedRunId = "20260714-120001-a1b2c4"; + await withServer({ + runs: [ + { id: legacyRunId, schemaVersion: 3, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail }, + { id: unsupportedRunId, schemaVersion: 99, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail }, + ], + }, async ({ baseUrl, repoRoot }) => { + const legacy = JSON.parse((await httpGet(baseUrl, `/api/runs/${legacyRunId}`)).body).run; + assert.equal(legacy.schemaVersion, 3); + assert.equal(legacy.ovenRevision, expectedLegacyRevision); + const unsupported = await httpGet(baseUrl, `/api/runs/${unsupportedRunId}`); + assert.equal(unsupported.status, 400); + assert.match(JSON.parse(unsupported.body).error, /schemaVersion must be 3 or 4/u); + + const ovens = JSON.parse((await httpGet(baseUrl, "/api/ovens")).body); + const created = await httpRequest(baseUrl, "/api/runs", { + method: "POST", + headers: { + "content-type": "application/json", + "x-burnlist-token": ovens.writeToken, + }, + body: JSON.stringify({ ovenId: "checklist", repoRoot, title: "Current run", objective: "Verify revision pinning." }), + }); + assert.equal(created.status, 201); + const current = JSON.parse(created.body).run; + assert.equal(current.schemaVersion, 4); + assert.match(current.ovenRevision, /^o1-sha256:[a-f0-9]{64}$/u); + const reread = JSON.parse((await httpGet(baseUrl, `/api/runs/${current.id}`)).body).run; + assert.equal(reread.ovenRevision, current.ovenRevision); + }); +}); + +async function withServer({ withBurnlist, burnlists, ovenData = [], ovens = [], runs = [], scanRoots }, callback) { const fixtureRoot = await mkdtemp(join(tmpdir(), "burnlist-dashboard-routes-")); const homeRoot = join(fixtureRoot, "home"); const fixtures = burnlists ?? (withBurnlist ? [{}] : []); @@ -131,6 +194,8 @@ async function withServer({ withBurnlist, burnlists, ovenData = [], scanRoots }, join(fixtureRoot, `${id}.json`), JSON.stringify(payload), ))); + await Promise.all(ovens.map((oven) => writeOvenFixture(fixtureRoot, oven))); + await Promise.all(runs.map((run) => writeRunFixture(fixtureRoot, run))); const port = await availablePort(); const ovenDataBindings = ovenData.map(({ id }) => `${id}=${join(fixtureRoot, `${id}.json`)}`).join(","); child = spawn(process.execPath, [ @@ -146,7 +211,11 @@ async function withServer({ withBurnlist, burnlists, ovenData = [], scanRoots }, stdio: ["ignore", "pipe", "pipe"], }); const baseUrl = await waitForServer(child); - return await callback({ baseUrl, planPath: planPaths[0], planPaths }); + // The server canonicalizes scan roots (macOS /var → /private/var), so hand back the realpath'd + // repo root; POST /api/runs requires the repoRoot to match a canonical scan root. + const rawRepoRoot = join(fixtureRoot, rootPaths[0]); + const repoRoot = existsSync(rawRepoRoot) ? realpathSync(rawRepoRoot) : rawRepoRoot; + return await callback({ baseUrl, planPath: planPaths[0], planPaths, repoRoot }); } finally { await stopChild(child); await rm(fixtureRoot, { recursive: true, force: true }); @@ -182,6 +251,68 @@ function burnlistMarkdown(title) { ].join("\n"); } +function detailFixture() { + return { + version: 1, + columns: 2, + rows: 2, + rowHeight: 48, + cells: [{ + id: "summary", + title: "Summary", + description: "Current status.", + widget: "metric", + source: "/summary", + format: "plain", + column: 1, + row: 1, + columnSpan: 2, + rowSpan: 1, + }], + }; +} + +async function writeOvenFixture(fixtureRoot, fixture) { + const ovenRoot = join(fixtureRoot, ".local", "burnlist", "ovens", fixture.id); + await mkdir(ovenRoot, { recursive: true }); + await Promise.all([ + writeFile(join(ovenRoot, "instructions.md"), fixture.instructions ?? "# Fixture Oven\n\nFollow the checklist.\n"), + writeFile(join(ovenRoot, "detail.json"), JSON.stringify(fixture.detail ?? detailFixture())), + ...(fixture.ovenJson === undefined + ? [] + : [writeFile(join(ovenRoot, "oven.json"), typeof fixture.ovenJson === "string" ? fixture.ovenJson : JSON.stringify(fixture.ovenJson))]), + ]); +} + +async function writeRunFixture(fixtureRoot, fixture) { + const repoRoot = join(fixtureRoot, fixture.repoPath ?? "fixture-repo"); + const runRoot = join(repoRoot, ".local", "burnlist", "runs", fixture.id); + const createdAt = "2026-07-14T12:00:00.000Z"; + const record = { + schemaVersion: fixture.schemaVersion, + id: fixture.id, + ovenId: fixture.ovenId, + repoRoot, + repo: "fixture-repo", + title: "Fixture run", + status: "requested", + createdAt, + updatedAt: createdAt, + inputs: { objective: "Exercise run reads." }, + summary: {}, + sections: [], + ...(fixture.ovenRevision ? { ovenRevision: fixture.ovenRevision } : {}), + }; + // The repo must be discoverable (repoRoot() requires notes/burnlists) for readBurnRun to search it. + await mkdir(join(repoRoot, "notes", "burnlists", "inprogress"), { recursive: true }); + await mkdir(runRoot, { recursive: true }); + await Promise.all([ + writeFile(join(runRoot, "run.json"), JSON.stringify(record)), + writeFile(join(runRoot, "instructions.md"), fixture.instructions), + writeFile(join(runRoot, "detail.json"), JSON.stringify(fixture.detail)), + ]); +} + function httpGet(baseUrl, path) { return new Promise((resolveResponse, reject) => { const request = get(new URL(path, baseUrl), (response) => { @@ -197,6 +328,19 @@ function httpGet(baseUrl, path) { }); } +function httpRequest(baseUrl, path, { method, headers, body }) { + return new Promise((resolveResponse, reject) => { + const req = request(new URL(path, baseUrl), { method, headers }, (response) => { + const chunks = []; + response.setEncoding("utf8"); + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolveResponse({ status: response.statusCode, body: chunks.join("") })); + }); + req.once("error", reject); + req.end(body); + }); +} + function availablePort() { return new Promise((resolvePort, reject) => { const probe = createServer(); From c2e2b3f8f60c286530d5666f4db30069e2c389b6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 00:43:06 +0200 Subject: [PATCH 04/30] fix: isolate registered ovens from malformed packages and verify run revisions --- scripts/verify.mjs | 1 + src/ovens/handlers/generic-json-handler.mjs | 6 ++- src/server/burnlist-dashboard-server.mjs | 47 ++++++++++++--------- src/server/dashboard-routes.test.mjs | 44 ++++++++++++++----- src/server/oven-bindings.mjs | 11 ++--- src/server/oven-bindings.test.mjs | 19 ++++++--- src/server/oven-warm.mjs | 11 +++++ src/server/oven-warm.test.mjs | 9 ++++ 8 files changed, 104 insertions(+), 44 deletions(-) create mode 100644 src/server/oven-warm.mjs create mode 100644 src/server/oven-warm.test.mjs diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 366e2bd..3ebc184 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -438,6 +438,7 @@ run(process.execPath, [ "src/cli/oven-cli.test.mjs", "src/cli/umbrella.test.mjs", "src/server/oven-bindings.test.mjs", + "src/server/oven-warm.test.mjs", "src/server/registry.test.mjs", "src/server/repo-map.test.mjs", "src/server/repo-state.test.mjs", diff --git a/src/ovens/handlers/generic-json-handler.mjs b/src/ovens/handlers/generic-json-handler.mjs index b2cd642..4d77add 100644 --- a/src/ovens/handlers/generic-json-handler.mjs +++ b/src/ovens/handlers/generic-json-handler.mjs @@ -1,6 +1,8 @@ import { readTextFileWithLimit, safeStat } from "../../server/fs-safe.mjs"; export const genericJsonHandler = Object.freeze({ + id: "checklist", + serveData({ id, bindingPath, maxOvenDataBytes }) { if (!safeStat(bindingPath)?.isFile()) { throw Object.assign(new Error(`configured data for Oven ${id} is missing`), { status: 404 }); @@ -9,8 +11,8 @@ export const genericJsonHandler = Object.freeze({ return { ovenId: id, path: bindingPath, payload }; }, - dashboardEntries({ oven, discoverBurnlists }) { - if (oven.id !== "checklist") return []; + dashboardEntries({ id, discoverBurnlists }) { + if (id !== "checklist") return []; return discoverBurnlists().map((entry) => ({ ...entry, ovenId: "checklist", diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 31c02e9..823bc3b 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -33,6 +33,7 @@ import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; import { readTextFileWithLimit, safeStat } from "./fs-safe.mjs"; +import { warmOvenHandler } from "./oven-warm.mjs"; import { LIFECYCLES, burnlistIdForPlan, @@ -252,9 +253,9 @@ function discoverBurnlists() { } function dashboardEntries(ovenDataBindings = resolvedOvenDataBindings()) { - return discoverOvens().flatMap((oven) => { - const handler = getOvenHandler(oven.id) ?? genericJsonHandler; - return handler.dashboardEntries?.(ovenHandlerContext({ oven, ovenDataBindings })) ?? []; + return listOvenHandlers().flatMap((handler) => { + const id = handler.id; + return handler.dashboardEntries?.(ovenHandlerContext({ id, oven: { id }, ovenDataBindings })) ?? []; }) .map((entry) => { let key = null; @@ -559,20 +560,24 @@ function readBurnRun(id) { if (![3, 4].includes(record.schemaVersion)) { throw new Error("Burn run schemaVersion must be 3 or 4."); } + const runRoot = dirname(path); + const ovenPackage = normalizeOvenPackage({ + id: record.ovenId, + instructions: readTextFileWithLimit(join(runRoot, "instructions.md"), 65536, "Run Oven instructions"), + detail: JSON.parse(readTextFileWithLimit(join(runRoot, "detail.json"), 131072, "Run Oven detail template")), + }); + const snapshotRevision = ovenRevision(ovenPackage); if (record.schemaVersion === 4) { const ovenRevisionValue = boundedText(record.ovenRevision, "Burn run ovenRevision", 74); if (!/^o1-sha256:[a-f0-9]{64}$/u.test(ovenRevisionValue)) { throw new Error("Burn run ovenRevision must be an o1-sha256 digest."); } + if (ovenRevisionValue !== snapshotRevision) { + throw new Error(`Burn run ${safeId} revision does not match its snapshot.`); + } return record; } - const runRoot = dirname(path); - const ovenPackage = normalizeOvenPackage({ - id: record.ovenId, - instructions: readTextFileWithLimit(join(runRoot, "instructions.md"), 65536, "Run Oven instructions"), - detail: JSON.parse(readTextFileWithLimit(join(runRoot, "detail.json"), 131072, "Run Oven detail template")), - }); - return { ...record, ovenRevision: ovenRevision(ovenPackage) }; + return { ...record, ovenRevision: snapshotRevision }; } return null; } @@ -963,15 +968,17 @@ const server = createServer(async (req, res) => { if (ovenDataRoute) { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); const id = ovenDataRoute[1]; - const oven = discoverOvens().find((entry) => entry.id === id); + const handler = getOvenHandler(id); const bindingPath = ovenDataBindings.get(id); - if (!oven && !bindingPath) { - return json(res, 404, { validated: false, error: `no data binding configured for Oven ${id}` }); + let oven = null; + if (!handler) { + oven = discoverOvens().find((entry) => entry.id === id) ?? null; + if (!oven) return json(res, 404, { validated: false, error: `Oven ${id} is not available` }); } if (!bindingPath) return json(res, 404, { error: `no data binding configured for Oven ${id}` }); try { - const handler = getOvenHandler(id) ?? genericJsonHandler; - const response = handler.serveData?.(ovenHandlerContext({ id, oven, req, res, url, bindingPath, ovenDataBindings })); + const active = handler ?? genericJsonHandler; + const response = active.serveData?.(ovenHandlerContext({ id, oven, req, res, url, bindingPath, ovenDataBindings })); if (response !== undefined) json(res, 200, response); } catch (error) { json(res, Number.isInteger(error.status) ? error.status : 422, { @@ -1059,11 +1066,11 @@ if (!reportMode) { for (const handler of listOvenHandlers()) { if (typeof handler.warm !== "function" || !handler.warmIntervalMs) continue; if (!handler.id) continue; - const warm = () => { - const ovenDataBindings = resolvedOvenDataBindings(); - if (!ovenDataBindings.has(handler.id)) return; - handler.warm(ovenHandlerContext({ id: handler.id, ovenDataBindings })); - }; + const warm = () => warmOvenHandler( + handler, + resolvedOvenDataBindings, + (context) => ovenHandlerContext(context), + ); warm(); const warmTimer = setInterval(warm, handler.warmIntervalMs); warmTimer.unref(); diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index e43970a..39f46ce 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -78,13 +78,19 @@ test("/api/burnlists lists discovered Burnlists across the observer set", { time }); }); -test("Oven data uses registered and generic handlers while unknown ids are unvalidated", { timeout: 20_000 }, async () => { +test("registered Oven routes and dashboard entries ignore malformed custom Oven packages", { timeout: 20_000 }, async () => { + const timestamp = "2026-01-01T12:00:00.000Z"; const differentialTestingPayload = buildPayload( - { captureId: "reference-fixture", generatedAt: "2026-01-01T12:00:00.000Z", fields: [], samples: [] }, - { captureId: "candidate-fixture", generatedAt: "2026-01-01T12:00:00.000Z", samples: [] }, + { + captureId: "reference-fixture", generatedAt: timestamp, + fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], + samples: [{ tick: 0, values: { position: 1 } }], + }, + { captureId: "candidate-fixture", generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, ); await withServer({ withBurnlist: true, + ovens: [{ id: "malformed-oven", ovenJson: "{" }], ovenData: [ { id: "checklist", payload: { source: "generic" } }, { id: "differential-testing", payload: differentialTestingPayload }, @@ -94,19 +100,25 @@ test("Oven data uses registered and generic handlers while unknown ids are unval assert.equal(checklist.status, 200); assert.deepEqual(JSON.parse(checklist.body).payload, { source: "generic" }); - // The empty fixture has no scenarios, so the base document is served (selectedScenarioId null). const differentialTesting = await httpGet(baseUrl, "/api/oven-data/differential-testing"); assert.equal(differentialTesting.status, 200); - assert.equal(JSON.parse(differentialTesting.body).scenarioId, null); - - const unknown = await httpGet(baseUrl, "/api/oven-data/not-an-oven"); - assert.equal(unknown.status, 404); - assert.equal(JSON.parse(unknown.body).validated, false); + assert.equal(JSON.parse(differentialTesting.body).scenarioId, differentialTestingPayload.scenarioCatalog.selectedScenarioId); const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; assert.equal(entries.some((entry) => entry.ovenId === "checklist"), true); - // The empty DT payload publishes no scenarios, so its handler contributes no dashboard rows. - assert.equal(entries.some((entry) => entry.ovenId === "differential-testing"), false); + assert.equal(entries.some((entry) => entry.ovenId === "differential-testing"), true); + + const ovens = await httpGet(baseUrl, "/api/ovens"); + assert.equal(ovens.status, 400); + assert.match(JSON.parse(ovens.body).error, /lineage sidecar is invalid/u); + }); +}); + +test("an unknown Oven with a data binding remains unvalidated", { timeout: 20_000 }, async () => { + await withServer({ ovenData: [{ id: "ghost", payload: { ignored: true } }] }, async ({ baseUrl }) => { + const unknown = await httpGet(baseUrl, "/api/oven-data/ghost"); + assert.equal(unknown.status, 404); + assert.equal(JSON.parse(unknown.body).validated, false); }); }); @@ -140,15 +152,25 @@ test("Burn runs read legacy v3 revisions and write/read v4 revisions", { timeout })); const legacyRunId = "20260714-120000-a1b2c3"; const unsupportedRunId = "20260714-120001-a1b2c4"; + const matchingV4RunId = "20260714-120002-a1b2c5"; + const mismatchedV4RunId = "20260714-120003-a1b2c6"; await withServer({ runs: [ { id: legacyRunId, schemaVersion: 3, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail }, { id: unsupportedRunId, schemaVersion: 99, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail }, + { id: matchingV4RunId, schemaVersion: 4, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail, ovenRevision: expectedLegacyRevision }, + { id: mismatchedV4RunId, schemaVersion: 4, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail, ovenRevision: `o1-sha256:${"f".repeat(64)}` }, ], }, async ({ baseUrl, repoRoot }) => { const legacy = JSON.parse((await httpGet(baseUrl, `/api/runs/${legacyRunId}`)).body).run; assert.equal(legacy.schemaVersion, 3); assert.equal(legacy.ovenRevision, expectedLegacyRevision); + const matchingV4 = await httpGet(baseUrl, `/api/runs/${matchingV4RunId}`); + assert.equal(matchingV4.status, 200); + assert.equal(JSON.parse(matchingV4.body).run.ovenRevision, expectedLegacyRevision); + const mismatchedV4 = await httpGet(baseUrl, `/api/runs/${mismatchedV4RunId}`); + assert.equal(mismatchedV4.status, 400); + assert.match(JSON.parse(mismatchedV4.body).error, /revision does not match its snapshot/u); const unsupported = await httpGet(baseUrl, `/api/runs/${unsupportedRunId}`); assert.equal(unsupported.status, 400); assert.match(JSON.parse(unsupported.body).error, /schemaVersion must be 3 or 4/u); diff --git a/src/server/oven-bindings.mjs b/src/server/oven-bindings.mjs index 172fd00..3174399 100644 --- a/src/server/oven-bindings.mjs +++ b/src/server/oven-bindings.mjs @@ -106,9 +106,10 @@ export function removeBinding(repoRoot, id) { const cachedStores = new Map(); -function mtimeFor(path, stat) { +function statSignature(path, stat) { try { - return stat(path).mtimeMs; + const value = stat(path); + return [value.dev, value.ino, value.size, value.mtimeMs, value.ctimeMs].join(":"); } catch (error) { if (error?.code === "ENOENT") return null; throw error; @@ -117,11 +118,11 @@ function mtimeFor(path, stat) { function cachedStore(repoRoot, stat) { const path = bindingStorePath(repoRoot); - const mtimeMs = mtimeFor(path, stat); + const signature = statSignature(path, stat); const cached = cachedStores.get(path); - if (cached && cached.mtimeMs === mtimeMs) return cached.store; + if (cached && cached.signature === signature) return cached.store; const store = readBindingStore(repoRoot); - cachedStores.set(path, { mtimeMs, store }); + cachedStores.set(path, { signature, store }); return store; } diff --git a/src/server/oven-bindings.test.mjs b/src/server/oven-bindings.test.mjs index 9e7d9c1..4106ba9 100644 --- a/src/server/oven-bindings.test.mjs +++ b/src/server/oven-bindings.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -54,15 +54,22 @@ test("missing, corrupt, and obsolete binding stores fail closed", () => { } finally { cleanup(); } }); -test("effective bindings re-read a store after its mtime changes", () => { +test("effective bindings re-read an atomically replaced store with an identical mtime", () => { const { root, cleanup } = fixture(); try { + const unchangedMtime = new Date("2026-07-14T12:02:00.000Z"); writeBinding(root, "sample-oven", "first.json", BOUND_AT); + utimesSync(bindingStorePath(root), unchangedMtime, unchangedMtime); + const first = statSync(bindingStorePath(root)); assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven").path, resolve(root, "first.json")); - writeBinding(root, "sample-oven", "second.json", "2026-07-14T12:01:00.000Z"); - const changed = new Date("2026-07-14T12:02:00.000Z"); - utimesSync(bindingStorePath(root), changed, changed); - assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven").path, resolve(root, "second.json")); + writeBinding(root, "sample-oven", "a-longer-second.json", "2026-07-14T12:01:00.000Z"); + utimesSync(bindingStorePath(root), unchangedMtime, unchangedMtime); + const second = statSync(bindingStorePath(root)); + assert.equal(second.mtimeMs, first.mtimeMs); + assert.notEqual(second.ino, first.ino); + assert.notEqual(second.size, first.size); + assert.notEqual(second.ctimeMs, first.ctimeMs); + assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven").path, resolve(root, "a-longer-second.json")); } finally { cleanup(); } }); diff --git a/src/server/oven-warm.mjs b/src/server/oven-warm.mjs new file mode 100644 index 0000000..b4b8116 --- /dev/null +++ b/src/server/oven-warm.mjs @@ -0,0 +1,11 @@ +// Background Oven warming is opportunistic: data refresh failures must never +// prevent startup or escape an interval callback. +export function warmOvenHandler(handler, resolveBindings, createContext) { + try { + const ovenDataBindings = resolveBindings(); + if (!ovenDataBindings.has(handler.id)) return; + handler.warm(createContext({ id: handler.id, ovenDataBindings })); + } catch { + // Request routes report data failures; background warming stays silent. + } +} diff --git a/src/server/oven-warm.test.mjs b/src/server/oven-warm.test.mjs new file mode 100644 index 0000000..bcbaeb8 --- /dev/null +++ b/src/server/oven-warm.test.mjs @@ -0,0 +1,9 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { warmOvenHandler } from "./oven-warm.mjs"; + +test("warm guard swallows binding refresh and warm callback failures", () => { + const handler = { id: "sample-oven", warm() { throw new Error("unavailable"); } }; + assert.doesNotThrow(() => warmOvenHandler(handler, () => { throw new Error("refresh failed"); }, () => ({}))); + assert.doesNotThrow(() => warmOvenHandler(handler, () => new Map([[handler.id, "/tmp/data.json"]]), () => ({}))); +}); From 8e7d4eedc46ac40ce61c276f393cd6806877a694 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 00:53:13 +0200 Subject: [PATCH 05/30] fix: size run snapshot byte caps to normalized oven limits --- src/server/burnlist-dashboard-server.mjs | 19 ++++++++++--- src/server/dashboard-routes.test.mjs | 36 ++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 823bc3b..0fbdbd9 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -113,6 +113,8 @@ const writeToken = randomBytes(24).toString("hex"); const repoMapCache = new Map(); const ovenHandlerCaches = new Map(); const REPO_MAP_CACHE_MS = 2_000; +const RUN_SNAPSHOT_INSTRUCTIONS_MAX_BYTES = 262144; +const RUN_SNAPSHOT_DETAIL_MAX_BYTES = 393216; function cachedRepoMap(repo) { const key = repo.root; @@ -491,6 +493,11 @@ function runId(date = new Date()) { ].join(""); } +function assertSnapshotSize(contents, maxBytes, label) { + const bytes = Buffer.byteLength(contents); + if (bytes > maxBytes) throw new Error(`${label} snapshot exceeds ${maxBytes} bytes.`); +} + function createBurnRun(value) { assertKnownKeys(value, new Set(["ovenId", "repoRoot", "title", "objective"]), "Burn run"); const selectedOvenId = ovenId(value.ovenId); @@ -518,10 +525,14 @@ function createBurnRun(value) { summary: {}, sections: [], }; + const instructionsSnapshot = `${oven.instructions.trim()}\n`; + const detailSnapshot = `${JSON.stringify(oven.detail, null, 2)}\n`; + assertSnapshotSize(instructionsSnapshot, RUN_SNAPSHOT_INSTRUCTIONS_MAX_BYTES, "Run Oven instructions"); + assertSnapshotSize(detailSnapshot, RUN_SNAPSHOT_DETAIL_MAX_BYTES, "Run Oven detail template"); const files = { "run.json": `${JSON.stringify(record, null, 2)}\n`, - "instructions.md": `${oven.instructions.trim()}\n`, - "detail.json": `${JSON.stringify(oven.detail, null, 2)}\n`, + "instructions.md": instructionsSnapshot, + "detail.json": detailSnapshot, }; const path = withRepoStateLock(repo.root, () => { if (legacyRunsDir) return atomicDirectory(legacyRunsDir, id, files); @@ -563,8 +574,8 @@ function readBurnRun(id) { const runRoot = dirname(path); const ovenPackage = normalizeOvenPackage({ id: record.ovenId, - instructions: readTextFileWithLimit(join(runRoot, "instructions.md"), 65536, "Run Oven instructions"), - detail: JSON.parse(readTextFileWithLimit(join(runRoot, "detail.json"), 131072, "Run Oven detail template")), + instructions: readTextFileWithLimit(join(runRoot, "instructions.md"), RUN_SNAPSHOT_INSTRUCTIONS_MAX_BYTES, "Run Oven instructions"), + detail: JSON.parse(readTextFileWithLimit(join(runRoot, "detail.json"), RUN_SNAPSHOT_DETAIL_MAX_BYTES, "Run Oven detail template")), }); const snapshotRevision = ovenRevision(ovenPackage); if (record.schemaVersion === 4) { diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 39f46ce..90a70f1 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -193,6 +193,38 @@ test("Burn runs read legacy v3 revisions and write/read v4 revisions", { timeout }); }); +test("Burn runs read max-size normalized v4 Oven snapshots", { timeout: 20_000 }, async () => { + const maxText = (length) => "\u0800".repeat(length); + const instructions = `# ${maxText(65534)}`; + const detail = { + version: 1, columns: 24, rows: 32, rowHeight: 120, + cells: Array.from({ length: 32 }, (_, index) => ({ + id: `section-${String(index).padStart(2, "0")}-${"a".repeat(37)}`, + title: maxText(80), description: maxText(2000), widget: "comparison", + source: `/${maxText(159)}`, format: "timestamp", + column: (index % 24) + 1, row: Math.floor(index / 24) + 1, columnSpan: 1, rowSpan: 1, + })), + }; + const oven = normalizeOvenPackage({ id: "max-size-oven", instructions, detail }); + const instructionsSnapshot = `${oven.instructions}\n`; + const detailSnapshot = `${JSON.stringify(oven.detail, null, 2)}\n`; + assert.ok(Buffer.byteLength(instructionsSnapshot) > 65536); + assert.ok(Buffer.byteLength(detailSnapshot) > 131072); + assert.ok(Buffer.byteLength(instructionsSnapshot) <= 262144); + assert.ok(Buffer.byteLength(detailSnapshot) <= 393216); + const id = "20260714-120004-a1b2c7"; + await withServer({ + runs: [{ + id, schemaVersion: 4, ovenId: oven.id, instructions: oven.instructions, detail: oven.detail, + ovenRevision: ovenRevision(oven), instructionsSnapshot, detailSnapshot, + }], + }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, `/api/runs/${id}`); + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).run.ovenRevision, ovenRevision(oven)); + }); +}); + async function withServer({ withBurnlist, burnlists, ovenData = [], ovens = [], runs = [], scanRoots }, callback) { const fixtureRoot = await mkdtemp(join(tmpdir(), "burnlist-dashboard-routes-")); const homeRoot = join(fixtureRoot, "home"); @@ -330,8 +362,8 @@ async function writeRunFixture(fixtureRoot, fixture) { await mkdir(runRoot, { recursive: true }); await Promise.all([ writeFile(join(runRoot, "run.json"), JSON.stringify(record)), - writeFile(join(runRoot, "instructions.md"), fixture.instructions), - writeFile(join(runRoot, "detail.json"), JSON.stringify(fixture.detail)), + writeFile(join(runRoot, "instructions.md"), fixture.instructionsSnapshot ?? fixture.instructions), + writeFile(join(runRoot, "detail.json"), fixture.detailSnapshot ?? JSON.stringify(fixture.detail)), ]); } From f9e028bdeda51782e79260370fe17284826cff0c Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 01:15:01 +0200 Subject: [PATCH 06/30] fix: make lifecycle moves crash-safe with atomic locking --- src/cli/lifecycle-cli.test.mjs | 60 +++++++++++++++++++++++--- src/cli/lifecycle-moves.mjs | 53 +++++++++++------------ src/cli/lifecycle-moves.test.mjs | 73 ++++++++++++++++++++++++++++---- 3 files changed, 142 insertions(+), 44 deletions(-) diff --git a/src/cli/lifecycle-cli.test.mjs b/src/cli/lifecycle-cli.test.mjs index b028321..08f96ef 100644 --- a/src/cli/lifecycle-cli.test.mjs +++ b/src/cli/lifecycle-cli.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -180,19 +180,33 @@ test("start moves a ready Burnlist to inprogress", () => { } }); -test("lifecycle moves reject an existing target folder", () => { +test("lifecycle moves reject a populated target folder", () => { const context = fixture(); try { const result = newPlan(context); addActiveItem(result.planPath, context.repo); mkdirSync(lifecycleFolder(context.repo, "ready", result.id), { recursive: true }); + writeFileSync(join(lifecycleFolder(context.repo, "ready", result.id), "existing"), "keep\n"); assert.match(runFailure(context, "ready", result.id), /target exists/u); } finally { context.cleanup(); } }); -test("close requires a finished queue and writes a completion digest before moving", () => { +test("lifecycle moves reject an empty target folder", () => { + const context = fixture(); + try { + const result = newPlan(context); + addActiveItem(result.planPath, context.repo); + mkdirSync(lifecycleFolder(context.repo, "ready", result.id), { recursive: true }); + assert.match(runFailure(context, "ready", result.id), /target exists/u); + assert.equal(existsSync(lifecycleFolder(context.repo, "draft", result.id)), true); + } finally { + context.cleanup(); + } +}); + +test("close requires a finished queue and writes a completion digest in the target", () => { const context = fixture(); try { const result = newPlan(context); @@ -278,8 +292,7 @@ test("lifecycle verbs reject an existing per-id lock", () => { try { const result = newPlan(context); addActiveItem(result.planPath, context.repo); - mkdirSync(join(dirname(result.planPath), ".lock")); - writeFileSync(join(dirname(result.planPath), ".lock", "owner.json"), JSON.stringify({ token: "foreign", pid: process.pid })); + writeFileSync(join(dirname(result.planPath), ".lock"), JSON.stringify({ token: "foreign", pid: process.pid })); assert.match(runFailure(context, "ready", result.id), new RegExp(`${result.id} is busy \\(locked\\)`)); } finally { context.cleanup(); @@ -291,14 +304,47 @@ test("lifecycle verbs reclaim a dead lock owner", () => { try { const result = newPlan(context); addActiveItem(result.planPath, context.repo); - mkdirSync(join(dirname(result.planPath), ".lock")); - writeFileSync(join(dirname(result.planPath), ".lock", "pid"), "999999999\n"); + writeFileSync(join(dirname(result.planPath), ".lock"), JSON.stringify({ token: "dead", pid: 999999999 })); assert.match(run(context, "ready", result.id), new RegExp(`${result.id} draft -> ready`)); } finally { context.cleanup(); } }); +const gitAvailable = spawnSync("git", ["--version"], { stdio: "ignore" }).status === 0; + +test("full lifecycle from a linked worktree uses the primary repository", { skip: !gitAvailable }, () => { + const root = mkdtempSync(join(tmpdir(), "burnlist-lifecycle-worktree-")); + const primary = join(root, "primary"); + const linked = join(root, "linked"); + const home = join(root, "home"); + try { + mkdirSync(primary); + mkdirSync(home); + execFileSync("git", ["init", "--quiet", primary]); + execFileSync("git", ["-C", primary, "config", "user.email", "test@example.com"]); + execFileSync("git", ["-C", primary, "config", "user.name", "Burnlist Test"]); + mkdirSync(join(primary, "notes", "burnlists"), { recursive: true }); + writeFileSync(join(primary, "README.md"), "# primary\n"); + execFileSync("git", ["-C", primary, "add", "README.md"]); + execFileSync("git", ["-C", primary, "commit", "--quiet", "-m", "initial"]); + execFileSync("git", ["-C", primary, "worktree", "add", "--detach", linked], { stdio: "ignore" }); + + const context = { repo: linked, home }; + const result = newPlan(context); + assert.equal(result.planPath.startsWith(`${realpathSync(primary)}/`), true); + addActiveItem(result.planPath, primary); + run(context, "ready", result.id); + run(context, "start", result.id); + run(context, "burn", result.id, "B1"); + run(context, "close", result.id); + assert.equal(existsSync(lifecycleFolder(primary, "completed", result.id)), true); + assert.equal(existsSync(lifecycleFolder(linked, "completed", result.id)), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("dashboard stop leaves malformed and foreign live global runtime records alone", () => { const context = fixture(); try { diff --git a/src/cli/lifecycle-moves.mjs b/src/cli/lifecycle-moves.mjs index 561c401..d2cb265 100644 --- a/src/cli/lifecycle-moves.mjs +++ b/src/cli/lifecycle-moves.mjs @@ -56,15 +56,19 @@ function isPositivePid(pid) { return Number.isInteger(pid) && pid > 0; } -function lockOwner(lock) { +function readLock(lockPath) { try { - const owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")); - return isPositivePid(owner?.pid) && typeof owner.token === "string" && owner.token ? owner : null; + return JSON.parse(readFileSync(lockPath, "utf8")); } catch { return null; } } +function lockOwner(lockPath) { + const owner = readLock(lockPath); + return isPositivePid(owner?.pid) && typeof owner.token === "string" && owner.token ? owner : null; +} + function pidIsAlive(pid) { if (!isPositivePid(pid)) return false; try { @@ -78,45 +82,37 @@ function pidIsAlive(pid) { export function withLock(dir, fn) { let lockedDir = dir; const token = randomBytes(12).toString("hex"); - const lock = join(lockedDir, ".lock"); + const lockPath = join(lockedDir, ".lock"); + const writeLock = () => writeFileSync(lockPath, JSON.stringify({ token, pid: process.pid }), { flag: "wx" }); try { - mkdirSync(lock); + writeLock(); } catch (error) { if (error?.code !== "EEXIST") throw error; - const owner = lockOwner(lock); + const owner = lockOwner(lockPath); if (owner && pidIsAlive(owner.pid)) throw new Error(`${basename(dir)} is busy (locked)`); - const currentOwner = lockOwner(lock); - if (currentOwner?.token !== owner?.token) throw new Error(`${basename(dir)} is busy (locked)`); - const retired = `${lock}.${token}.stale`; try { - renameSync(lock, retired); + rmSync(lockPath, { force: true }); } catch (takeoverError) { - if (takeoverError?.code === "EEXIST" || takeoverError?.code === "ENOENT") { - throw new Error(`${basename(dir)} is busy (locked)`); - } throw takeoverError; } - rmSync(retired, { recursive: true, force: true }); try { - mkdirSync(lock); + writeLock(); } catch (takeoverError) { if (takeoverError?.code === "EEXIST") throw new Error(`${basename(dir)} is busy (locked)`); throw takeoverError; } } try { - atomicWrite(join(lock, "owner.json"), `${JSON.stringify({ token, pid: process.pid })}\n`); - } catch (error) { - if (lockOwner(lock)?.token === token) rmSync(lock, { recursive: true, force: true }); - throw error; - } - try { - const movedDir = fn(); + const movedDir = fn({ + retarget(movedDir) { + if (typeof movedDir === "string") lockedDir = movedDir; + }, + }); if (typeof movedDir === "string") lockedDir = movedDir; return movedDir; } finally { - const finalLock = join(lockedDir, ".lock"); - if (lockOwner(finalLock)?.token === token) rmSync(finalLock, { recursive: true, force: true }); + const finalLockPath = join(lockedDir, ".lock"); + if (readLock(finalLockPath)?.token === token) rmSync(finalLockPath, { force: true }); } } @@ -126,7 +122,7 @@ function appendCompletionDigestIfMissing(plan) { return true; } -export function moveLifecycle({ repoRoot, id, from, to, gate, beforeMove }) { +export function moveLifecycle({ repoRoot, id, from, to, gate, afterMove }) { assertValidBurnlistId(id); const sourceLifecycle = LIFECYCLES.find((lifecycle) => lifecycle.folder === from); const sourceDir = join(lifecycleRoot(repoRoot, sourceLifecycle), id); @@ -136,12 +132,11 @@ export function moveLifecycle({ repoRoot, id, from, to, gate, beforeMove }) { } const targetRoot = join(repoRoot, "notes", "burnlists", to); const targetDir = join(targetRoot, id); - return withLock(sourceDir, () => { + return withLock(sourceDir, ({ retarget }) => { const plan = parsePlan(join(sourceDir, "burnlist.md")); validateOrThrow(plan); gate(plan); if (safeStat(targetDir)) throw new Error(`${id}: target exists`); - beforeMove?.(plan); mkdirSync(targetRoot, { recursive: true }); if (safeStat(targetDir)) throw new Error(`${id}: target exists`); try { @@ -150,6 +145,8 @@ export function moveLifecycle({ repoRoot, id, from, to, gate, beforeMove }) { if (error?.code === "EEXIST" || error?.code === "ENOTEMPTY") throw new Error(`${id}: target exists`); throw error; } + retarget(targetDir); + afterMove?.(parsePlan(join(targetDir, "burnlist.md"))); console.log(`${id} ${from} -> ${to}`); return targetDir; }); @@ -187,7 +184,7 @@ export function closeLifecycle(repoRoot, id) { throw new Error("not ready to close: active checklist must be empty with completed entries"); } }, - beforeMove: appendCompletionDigestIfMissing, + afterMove: appendCompletionDigestIfMissing, }); } diff --git a/src/cli/lifecycle-moves.test.mjs b/src/cli/lifecycle-moves.test.mjs index 89b2442..8a50bce 100644 --- a/src/cli/lifecycle-moves.test.mjs +++ b/src/cli/lifecycle-moves.test.mjs @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { burnItem, findBurnlistDir, moveLifecycle, withLock } from "./lifecycle-moves.mjs"; +import { burnItem, closeLifecycle, findBurnlistDir, moveLifecycle, withLock } from "./lifecycle-moves.mjs"; function fixture() { const root = mkdtempSync(join(tmpdir(), "burnlist-lifecycle-moves-")); @@ -35,21 +36,21 @@ test("withLock takes over a dead owner and preserves a replacement owner on rele try { const dir = join(context.root, "260713-001"); const lock = join(dir, ".lock"); - mkdirSync(lock, { recursive: true }); - writeFileSync(join(lock, "owner.json"), JSON.stringify({ token: "dead", pid: 999999999 })); + mkdirSync(dir, { recursive: true }); + writeFileSync(lock, JSON.stringify({ token: "dead", pid: 999999999 })); let owner; withLock(dir, () => { - owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")); + owner = JSON.parse(readFileSync(lock, "utf8")); }); assert.equal(owner.pid, process.pid); assert.notEqual(owner.token, "dead"); assert.equal(existsSync(lock), false); withLock(dir, () => { - writeFileSync(join(lock, "owner.json"), JSON.stringify({ token: "replacement", pid: process.pid })); + writeFileSync(lock, JSON.stringify({ token: "replacement", pid: process.pid })); }); - assert.equal(JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")).token, "replacement"); - rmSync(lock, { recursive: true, force: true }); + assert.equal(JSON.parse(readFileSync(lock, "utf8")).token, "replacement"); + rmSync(lock, { force: true }); } finally { context.cleanup(); } @@ -60,14 +61,42 @@ test("withLock rejects a live foreign owner", () => { try { const dir = join(context.root, "260713-001"); const lock = join(dir, ".lock"); - mkdirSync(lock, { recursive: true }); - writeFileSync(join(lock, "owner.json"), JSON.stringify({ token: "foreign", pid: process.pid })); + mkdirSync(dir, { recursive: true }); + writeFileSync(lock, JSON.stringify({ token: "foreign", pid: process.pid })); assert.throws(() => withLock(dir, () => {}), /260713-001 is busy \(locked\)/u); } finally { context.cleanup(); } }); +test("two racing lock acquirers have exactly one winner", async () => { + const context = fixture(); + try { + const dir = join(context.root, "260713-001"); + mkdirSync(dir, { recursive: true }); + const script = [ + `import { withLock } from ${JSON.stringify(new URL("./lifecycle-moves.mjs", import.meta.url).href)};`, + "try {", + " withLock(process.argv[1], () => {", + " process.stdout.write('won\\n');", + " Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 750);", + " });", + "} catch (error) { process.stderr.write(error.message); process.exitCode = 1; }", + ].join("\n"); + const run = () => new Promise((resolve) => { + const child = spawn(process.execPath, ["--input-type=module", "--eval", script, dir]); + let output = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.on("close", (status) => resolve({ output, status })); + }); + const results = await Promise.all([run(), run()]); + assert.deepEqual(results.map((result) => result.status).sort(), [0, 1]); + assert.equal(results.filter((result) => result.output === "won\n").length, 1); + } finally { + context.cleanup(); + } +}); + test("burn rejects an id duplicated across lifecycle folders", () => { const context = fixture(); try { @@ -116,3 +145,29 @@ test("burn validates its staged markdown before replacing burnlist.md", () => { context.cleanup(); } }); + +test("close leaves its source unchanged when a populated target prevents the rename", () => { + const context = fixture(); + try { + const id = "260713-001"; + const { planPath } = writePlan(context.root, "inprogress", id); + writeFileSync(planPath, [ + "# Test Burnlist", + "", + "## Active Checklist", + "", + "## Completed", + "- B1 | 2026-07-13T12:00:00+00:00 | Test staged burn", + "", + ].join("\n")); + const before = readFileSync(planPath, "utf8"); + const target = folder(context.root, "completed", id); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "existing"), "keep\n"); + assert.throws(() => closeLifecycle(context.root, id), /260713-001: target exists/u); + assert.equal(readFileSync(planPath, "utf8"), before); + assert.equal(readFileSync(planPath, "utf8").includes("## Completion Digest"), false); + } finally { + context.cleanup(); + } +}); From 0fb066a1b29721b9eae183c68af9c6351c0bd0ab Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 01:19:09 +0200 Subject: [PATCH 07/30] fix: re-open filtered projects and route duplicate-id burnlists by plan path --- dashboard/src/App.tsx | 2 +- .../src/components/ProjectGroup/BurnlistRow.tsx | 8 +++++--- .../src/components/ProjectGroup/BurnlistTable.tsx | 4 ++-- .../src/components/ProjectGroup/ProjectGroup.tsx | 13 ++++++++++--- dashboard/src/lib/hrefs.ts | 5 ++++- dashboard/src/lib/project-open.mjs | 7 +++++++ dashboard/src/lib/project-open.test.mjs | 14 ++++++++++++++ dashboard/src/lib/types.ts | 3 ++- scripts/verify.mjs | 1 + src/ovens/handlers/generic-json-handler.mjs | 1 + src/server/dashboard-routes.test.mjs | 4 +++- src/server/projects-api.test.mjs | 6 ++++++ 12 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 dashboard/src/lib/project-open.mjs create mode 100644 dashboard/src/lib/project-open.test.mjs diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 06601f8..a068380 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -7,7 +7,7 @@ import type { Filter } from "@lib"; export function App() { const section = currentSection(); - const selected = useMemo(selectedBurnlist, [window.location.pathname]); + const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search]); const [filter, setFilter] = useState(() => filterFromUrl(FILTERS)); const { projects, progress, error, loading } = useDashboardData({ section, selected }); diff --git a/dashboard/src/components/ProjectGroup/BurnlistRow.tsx b/dashboard/src/components/ProjectGroup/BurnlistRow.tsx index 9d99591..383f79e 100644 --- a/dashboard/src/components/ProjectGroup/BurnlistRow.tsx +++ b/dashboard/src/components/ProjectGroup/BurnlistRow.tsx @@ -3,12 +3,14 @@ import { Button, Progress } from "@layout"; import { burnlistHref, formatTime } from "@lib"; import type { Burnlist, Filter } from "@lib"; -export function BurnlistRow({ entry, filter }: { entry: Burnlist; filter: Filter }) { - const href = entry.ovenId === "checklist" ? burnlistHref(entry, filter) : entry.href; +export function BurnlistRow({ entry, filter, ambiguous }: { entry: Burnlist; filter: Filter; ambiguous: boolean }) { + const href = entry.ovenId === "checklist" ? burnlistHref(entry, filter, ambiguous) : entry.href; const open = () => { window.location.href = href; }; const copy = (event: MouseEvent) => { event.stopPropagation(); - const url = window.location.origin + (entry.ovenId === "checklist" + const url = window.location.origin + (entry.ovenId === "checklist" && ambiguous + ? href + : entry.ovenId === "checklist" ? (entry.repoKey ? `/r/${encodeURIComponent(entry.repoKey)}/${encodeURIComponent(entry.id)}` : `/${encodeURIComponent(entry.repo)}/${encodeURIComponent(entry.id)}`) : entry.href); void navigator.clipboard?.writeText(url); diff --git a/dashboard/src/components/ProjectGroup/BurnlistTable.tsx b/dashboard/src/components/ProjectGroup/BurnlistTable.tsx index 986dc73..ed8bba7 100644 --- a/dashboard/src/components/ProjectGroup/BurnlistTable.tsx +++ b/dashboard/src/components/ProjectGroup/BurnlistTable.tsx @@ -1,7 +1,7 @@ import { BurnlistRow } from "./BurnlistRow"; import type { Filter, Project } from "@lib"; -export function BurnlistTable({ entries, filter, emptyLabel }: { entries: Project["entries"]; filter: Filter; emptyLabel: string }) { +export function BurnlistTable({ entries, filter, emptyLabel, ambiguousIds }: { entries: Project["entries"]; filter: Filter; emptyLabel: string; ambiguousIds: string[] }) { return (

@@ -23,7 +23,7 @@ export function BurnlistTable({ entries, filter, emptyLabel }: { entries: Projec - {entries.length ? entries.map((entry) => ) : ( + {entries.length ? entries.map((entry) => ) : (

{emptyLabel}

diff --git a/dashboard/src/components/ProjectGroup/ProjectGroup.tsx b/dashboard/src/components/ProjectGroup/ProjectGroup.tsx index 99ad1e4..f239804 100644 --- a/dashboard/src/components/ProjectGroup/ProjectGroup.tsx +++ b/dashboard/src/components/ProjectGroup/ProjectGroup.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Badge } from "@layout"; +import { projectGroupOpen, projectGroupShouldResetOpen } from "@lib/project-open"; import type { Filter, Project } from "@lib"; import { BurnlistTable } from "./BurnlistTable"; @@ -7,7 +8,13 @@ export function ProjectGroup({ project, filter }: { project: Project; filter: Fi // Uncontrolled-with-state: initial open when the current filter has rows, and the user's // toggle survives the 5s poll re-render (a bare `open` prop would fight the poll). const filteredEntries = project.entries.filter((entry) => filter === "all" || entry.status === filter); - const [open, setOpen] = useState(() => filteredEntries.length > 0 || project.counts.total === 0); + const derivedOpen = projectGroupOpen(filteredEntries, project.counts.total); + const [open, setOpen] = useState(() => derivedOpen); + const previousFilter = useRef(filter); + if (projectGroupShouldResetOpen(previousFilter.current, filter)) { + previousFilter.current = filter; + setOpen(derivedOpen); + } const badge = `${project.registered ? "registered" : "observed"} · ${project.health}`; const emptyLabel = project.counts.total === 0 ? "no burnlists yet — run `burnlist new` here" @@ -20,7 +27,7 @@ export function ProjectGroup({ project, filter }: { project: Project; filter: Fi {project.counts.total} lists · {project.counts.active} active {badge} - + ); } diff --git a/dashboard/src/lib/hrefs.ts b/dashboard/src/lib/hrefs.ts index 48c9dbd..7bd9f93 100644 --- a/dashboard/src/lib/hrefs.ts +++ b/dashboard/src/lib/hrefs.ts @@ -9,6 +9,8 @@ export function currentSection() { 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; @@ -23,7 +25,8 @@ export function listHref(filter: Filter) { return filter === "all" ? "/" : `/?filter=${encodeURIComponent(filter)}`; } -export function burnlistHref(entry: Burnlist, 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)}`; diff --git a/dashboard/src/lib/project-open.mjs b/dashboard/src/lib/project-open.mjs new file mode 100644 index 0000000..f4ecb9d --- /dev/null +++ b/dashboard/src/lib/project-open.mjs @@ -0,0 +1,7 @@ +export function projectGroupShouldResetOpen(previousFilter, filter) { + return previousFilter !== filter; +} + +export function projectGroupOpen(filteredEntries, total) { + return filteredEntries.length > 0 || total === 0; +} diff --git a/dashboard/src/lib/project-open.test.mjs b/dashboard/src/lib/project-open.test.mjs new file mode 100644 index 0000000..ca4f0ac --- /dev/null +++ b/dashboard/src/lib/project-open.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { projectGroupOpen, projectGroupShouldResetOpen } from "./project-open.mjs"; + +test("project groups reset their derived open state only for lifecycle filter changes", () => { + const active = [{ id: "active" }]; + const completed = [{ id: "completed" }]; + assert.equal(projectGroupOpen(active, 1), true); + assert.equal(projectGroupOpen([], 1), false); + assert.equal(projectGroupOpen([], 0), true); + assert.equal(projectGroupShouldResetOpen("active", "active"), false); + assert.equal(projectGroupShouldResetOpen("active", "complete"), true); + assert.equal(projectGroupOpen(completed, 1), true); +}); diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 81ce43e..f95a373 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -26,6 +26,7 @@ export type Burnlist = { repo: string; repoKey: string | null; repoRoot: string | null; + planPath: string; title: string; planLabel: string; status: Exclude; @@ -57,5 +58,5 @@ export type Project = { ambiguousIds: string[]; }; -export type SelectedBurnlist = { repo?: string; repoKey?: string; id: string }; +export type SelectedBurnlist = { repo?: string; repoKey?: string; id?: string; plan?: string }; export type ProgressData = ChecklistProgressData; diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 3ebc184..211b257 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -442,6 +442,7 @@ run(process.execPath, [ "src/server/registry.test.mjs", "src/server/repo-map.test.mjs", "src/server/repo-state.test.mjs", + "dashboard/src/lib/project-open.test.mjs", ]); run(process.execPath, ["scripts/register-skills.mjs", "--force-global", "--dry-run"], { diff --git a/src/ovens/handlers/generic-json-handler.mjs b/src/ovens/handlers/generic-json-handler.mjs index 4d77add..d20c636 100644 --- a/src/ovens/handlers/generic-json-handler.mjs +++ b/src/ovens/handlers/generic-json-handler.mjs @@ -15,6 +15,7 @@ export const genericJsonHandler = Object.freeze({ if (id !== "checklist") return []; return discoverBurnlists().map((entry) => ({ ...entry, + planPath: entry.planPath, ovenId: "checklist", ovenName: "Checklist", href: `/${encodeURIComponent(entry.repo)}/${encodeURIComponent(entry.id)}`, diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 90a70f1..8499ed1 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -74,7 +74,9 @@ test("/api/burnlists lists discovered Burnlists across the observer set", { time assert.equal(response.status, 200); const payload = JSON.parse(response.body); assert.equal(Array.isArray(payload.burnlists), true); - assert.equal(payload.burnlists.some((entry) => entry.id === "fixture"), true); + const entry = payload.burnlists.find((candidate) => candidate.id === "fixture"); + assert.ok(entry); + assert.equal(typeof entry.planPath, "string"); }); }); diff --git a/src/server/projects-api.test.mjs b/src/server/projects-api.test.mjs index a6b1c0e..777e8eb 100644 --- a/src/server/projects-api.test.mjs +++ b/src/server/projects-api.test.mjs @@ -68,6 +68,12 @@ test("/api/projects reports ids duplicated across lifecycle folders", { timeout: assert.equal(response.status, 200); const [project] = JSON.parse(response.body).projects; assert.deepEqual(project.ambiguousIds, ["repeated"]); + assert.deepEqual(project.entries.map((entry) => entry.planPath).sort(), [...fixture.planPaths].sort()); + for (const entry of project.entries) { + const progress = await httpGet(fixture.baseUrl, `/api/progress?plan=${encodeURIComponent(entry.planPath)}`); + assert.equal(progress.status, 200); + assert.equal(JSON.parse(progress.body).planPath, entry.planPath); + } }); }); From c3357612769c855ba77e18fbde8f3206959ab830 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 01:29:29 +0200 Subject: [PATCH 08/30] fix: close remaining lock and rename races in lifecycle moves --- src/cli/lifecycle-moves.mjs | 65 ++++++++++++++++--------- src/cli/lifecycle-moves.test.mjs | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 23 deletions(-) diff --git a/src/cli/lifecycle-moves.mjs b/src/cli/lifecycle-moves.mjs index d2cb265..a5e176e 100644 --- a/src/cli/lifecycle-moves.mjs +++ b/src/cli/lifecycle-moves.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { LIFECYCLES, @@ -69,38 +69,47 @@ function lockOwner(lockPath) { return isPositivePid(owner?.pid) && typeof owner.token === "string" && owner.token ? owner : null; } -function pidIsAlive(pid) { +function pidIsDead(pid) { if (!isPositivePid(pid)) return false; try { process.kill(pid, 0); - return true; + return false; } catch (error) { - return error?.code !== "ESRCH"; + return error?.code === "ESRCH"; } } export function withLock(dir, fn) { let lockedDir = dir; - const token = randomBytes(12).toString("hex"); + const token = randomBytes(16).toString("hex"); const lockPath = join(lockedDir, ".lock"); - const writeLock = () => writeFileSync(lockPath, JSON.stringify({ token, pid: process.pid }), { flag: "wx" }); + const temporary = join(lockedDir, `.lock.${token}.tmp`); + const busy = () => new Error(`${basename(dir)} is busy (locked)`); try { - writeLock(); - } catch (error) { - if (error?.code !== "EEXIST") throw error; - const owner = lockOwner(lockPath); - if (owner && pidIsAlive(owner.pid)) throw new Error(`${basename(dir)} is busy (locked)`); + writeFileSync(temporary, JSON.stringify({ token, pid: process.pid })); try { - rmSync(lockPath, { force: true }); - } catch (takeoverError) { - throw takeoverError; - } - try { - writeLock(); - } catch (takeoverError) { - if (takeoverError?.code === "EEXIST") throw new Error(`${basename(dir)} is busy (locked)`); - throw takeoverError; + linkSync(temporary, lockPath); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + const owner = lockOwner(lockPath); + if (!owner || !pidIsDead(owner.pid)) throw busy(); + const claim = `${lockPath}.claim.${token}`; + try { + renameSync(lockPath, claim); + } catch (takeoverError) { + if (takeoverError?.code === "ENOENT") throw busy(); + throw takeoverError; + } + try { + rmSync(claim, { force: true }); + linkSync(temporary, lockPath); + } catch (takeoverError) { + if (takeoverError?.code === "EEXIST") throw busy(); + throw takeoverError; + } } + } finally { + rmSync(temporary, { force: true }); } try { const movedDir = fn({ @@ -136,17 +145,27 @@ export function moveLifecycle({ repoRoot, id, from, to, gate, afterMove }) { const plan = parsePlan(join(sourceDir, "burnlist.md")); validateOrThrow(plan); gate(plan); - if (safeStat(targetDir)) throw new Error(`${id}: target exists`); mkdirSync(targetRoot, { recursive: true }); - if (safeStat(targetDir)) throw new Error(`${id}: target exists`); + try { + mkdirSync(targetDir); + } catch (error) { + if (error?.code === "EEXIST") throw new Error(`${id}: target exists`); + throw error; + } try { renameSync(sourceDir, targetDir); } catch (error) { + rmSync(targetDir, { recursive: true, force: true }); if (error?.code === "EEXIST" || error?.code === "ENOTEMPTY") throw new Error(`${id}: target exists`); throw error; } + try { + afterMove?.(parsePlan(join(targetDir, "burnlist.md"))); + } catch (error) { + renameSync(targetDir, sourceDir); + throw error; + } retarget(targetDir); - afterMove?.(parsePlan(join(targetDir, "burnlist.md"))); console.log(`${id} ${from} -> ${to}`); return targetDir; }); diff --git a/src/cli/lifecycle-moves.test.mjs b/src/cli/lifecycle-moves.test.mjs index 8a50bce..fdd0657 100644 --- a/src/cli/lifecycle-moves.test.mjs +++ b/src/cli/lifecycle-moves.test.mjs @@ -69,6 +69,20 @@ test("withLock rejects a live foreign owner", () => { } }); +test("withLock treats a malformed lock as busy", () => { + const context = fixture(); + try { + const dir = join(context.root, "260713-001"); + const lock = join(dir, ".lock"); + mkdirSync(dir, { recursive: true }); + writeFileSync(lock, ""); + assert.throws(() => withLock(dir, () => {}), /260713-001 is busy \(locked\)/u); + assert.equal(readFileSync(lock, "utf8"), ""); + } finally { + context.cleanup(); + } +}); + test("two racing lock acquirers have exactly one winner", async () => { const context = fixture(); try { @@ -97,6 +111,29 @@ test("two racing lock acquirers have exactly one winner", async () => { } }); +test("concurrent same-id lifecycle moves publish only one target", async () => { + const context = fixture(); + try { + const id = "260713-001"; + writePlan(context.root, "draft", id); + const script = [ + `import { moveLifecycle } from ${JSON.stringify(new URL("./lifecycle-moves.mjs", import.meta.url).href)};`, + "try {", + " moveLifecycle({ repoRoot: process.argv[1], id: process.argv[2], from: 'draft', to: 'ready', gate() {} });", + "} catch (error) { process.stderr.write(error.message); process.exitCode = 1; }", + ].join("\n"); + const run = () => new Promise((resolve) => { + const child = spawn(process.execPath, ["--input-type=module", "--eval", script, context.root, id]); + child.on("close", (status) => resolve(status)); + }); + assert.deepEqual((await Promise.all([run(), run()])).sort(), [0, 1]); + assert.equal(existsSync(folder(context.root, "draft", id)), false); + assert.equal(existsSync(folder(context.root, "ready", id)), true); + } finally { + context.cleanup(); + } +}); + test("burn rejects an id duplicated across lifecycle folders", () => { const context = fixture(); try { @@ -171,3 +208,47 @@ test("close leaves its source unchanged when a populated target prevents the ren context.cleanup(); } }); + +test("close leaves its source unchanged when an empty target already exists", () => { + const context = fixture(); + try { + const id = "260713-001"; + const { planPath } = writePlan(context.root, "inprogress", id); + writeFileSync(planPath, [ + "# Test Burnlist", + "", + "## Active Checklist", + "", + "## Completed", + "- B1 | 2026-07-13T12:00:00+00:00 | Test staged burn", + "", + ].join("\n")); + const before = readFileSync(planPath, "utf8"); + mkdirSync(folder(context.root, "completed", id), { recursive: true }); + assert.throws(() => closeLifecycle(context.root, id), /260713-001: target exists/u); + assert.equal(readFileSync(planPath, "utf8"), before); + } finally { + context.cleanup(); + } +}); + +test("afterMove failure rolls back a lifecycle move without changing its plan", () => { + const context = fixture(); + try { + const id = "260713-001"; + const { planPath } = writePlan(context.root, "inprogress", id); + const before = readFileSync(planPath, "utf8"); + assert.throws(() => moveLifecycle({ + repoRoot: context.root, + id, + from: "inprogress", + to: "completed", + gate() {}, + afterMove() { throw new Error("digest failed"); }, + }), /digest failed/u); + assert.equal(readFileSync(planPath, "utf8"), before); + assert.equal(existsSync(folder(context.root, "completed", id)), false); + } finally { + context.cleanup(); + } +}); From f3bd92926e3a50ca48840e9ffc5b650789946fa3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 01:59:24 +0200 Subject: [PATCH 09/30] ci: add verify workflow and manual npm publish workflow --- .github/workflows/ci.yml | 40 +++++++++++++++++ .github/workflows/publish.yml | 82 +++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2b4bdde --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build dashboard + run: npm run build:dashboard + + - name: Verify + run: npm run verify + + - name: Verify package payload + run: npm run verify:package diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..14c7895 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,82 @@ +name: Publish + +# Manual-only. Bumps the package version (patch/minor/major), gates it, publishes to +# npm, then commits the version bump and pushes a `v` tag back to main. +# +# Trigger from the Actions tab ("Run workflow") or: +# gh workflow run publish.yml -f bump=patch +# Requires an `NPM_TOKEN` repository secret with publish rights. +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump" + required: true + default: "patch" + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +concurrency: + group: publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Gate — build, verify, package + run: | + npm run build:dashboard + npm run verify + npm run verify:package + + - name: Bump version + id: bump + run: | + set -euo pipefail + next="$(npm version "${{ inputs.bump }}" --no-git-tag-version)" + echo "version=${next}" >> "$GITHUB_OUTPUT" + echo "Bumped to ${next}" + + - name: Publish to npm + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Commit, tag, and push + env: + NEXT_VERSION: ${{ steps.bump.outputs.version }} + ACTOR: ${{ github.actor }} + ACTOR_ID: ${{ github.actor_id }} + run: | + set -euo pipefail + git config user.name "$ACTOR" + git config user.email "${ACTOR_ID}+${ACTOR}@users.noreply.github.com" + git add package.json package-lock.json + git commit -m "chore(release): ${NEXT_VERSION}" + git tag "${NEXT_VERSION}" + git push origin HEAD:main "${NEXT_VERSION}" From 63c0aa6bdebd9662329a26941671e7e2924a8d32 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 02:50:32 +0200 Subject: [PATCH 10/30] ci: guard publish to main, push before publish, test node 18/20/22 --- .github/workflows/ci.yml | 6 +++++- .github/workflows/publish.yml | 21 ++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b4bdde..35f7e00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,10 @@ jobs: verify: runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node-version: [18, 20, 22] steps: - name: Check out uses: actions/checkout@v4 @@ -23,7 +27,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: npm cache-dependency-path: package-lock.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 14c7895..4c02bac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,6 +45,14 @@ jobs: cache: npm cache-dependency-path: package-lock.json + - name: Refuse to publish from a non-main ref + run: | + set -euo pipefail + if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then + echo "Publish must run from main; got ${GITHUB_REF}." + exit 1 + fi + - name: Install dependencies run: npm ci @@ -62,11 +70,9 @@ jobs: echo "version=${next}" >> "$GITHUB_OUTPUT" echo "Bumped to ${next}" - - name: Publish to npm - run: npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - + # Push the version bump to main BEFORE publishing: if the push fails, main and + # npm stay consistent (no npm release without a recorded commit/tag). The + # reverse order could publish to npm and then fail to record it on main. - name: Commit, tag, and push env: NEXT_VERSION: ${{ steps.bump.outputs.version }} @@ -80,3 +86,8 @@ jobs: git commit -m "chore(release): ${NEXT_VERSION}" git tag "${NEXT_VERSION}" git push origin HEAD:main "${NEXT_VERSION}" + + - name: Publish to npm + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 1a9513f0559e0847265ac64c146312ccaf16ca41 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 02:57:54 +0200 Subject: [PATCH 11/30] fix: atomic oven update, add oven fork lineage, drop legacy layout --- src/cli/oven-cli.mjs | 69 +++++++++++++++++---------------------- src/cli/oven-cli.test.mjs | 67 ++++++++++++++++++++++++++++++++++++- src/server/fs-safe.mjs | 20 ++++++++++-- 3 files changed, 113 insertions(+), 43 deletions(-) diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index 11905f2..dd438f7 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -7,12 +7,12 @@ // 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 { randomBytes } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; +import { atomicDirectory } from "../server/fs-safe.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; @@ -56,10 +56,8 @@ function bindingRepo() { // ── storage locations (mirror the dashboard server) ────────────────────────── const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const builtInOvensDir = resolve(packageRoot, "ovens"); -const legacyBuiltInTypesDir = resolve(packageRoot, "types"); const launchCwd = process.cwd(); const customOvensDir = resolve(launchCwd, flags.get("ovens-dir") ?? ".local/burnlist/ovens"); -const legacyCustomTypesDir = resolve(launchCwd, flags.get("types-dir") ?? ".local/burnlist/types"); function safeStat(path) { try { @@ -89,17 +87,11 @@ function instructionsDescription(instructions) { ); } -// Read one Oven directory, tolerating the legacy definition.md/dashboard.json -// filenames so `view`/`list` match what the dashboard discovers. function readOvenDir(root, id, builtIn) { const safeId = ovenId(id); const ovenRoot = join(root, safeId); - const instructionsPath = safeStat(join(ovenRoot, "instructions.md"))?.isFile() - ? join(ovenRoot, "instructions.md") - : join(ovenRoot, "definition.md"); - const detailPath = safeStat(join(ovenRoot, "detail.json"))?.isFile() - ? join(ovenRoot, "detail.json") - : join(ovenRoot, "dashboard.json"); + const instructionsPath = join(ovenRoot, "instructions.md"); + const detailPath = join(ovenRoot, "detail.json"); if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; const ovenPackage = normalizeOvenPackage({ id: safeId, @@ -148,8 +140,6 @@ function ovensIn(root, builtIn) { function discoverOvens() { const byId = new Map(); for (const oven of ovensIn(builtInOvensDir, true)) byId.set(oven.id, oven); - for (const oven of ovensIn(legacyBuiltInTypesDir, true)) if (!byId.has(oven.id)) byId.set(oven.id, oven); - for (const oven of ovensIn(legacyCustomTypesDir, false)) if (!byId.has(oven.id)) byId.set(oven.id, oven); for (const oven of ovensIn(customOvensDir, false)) if (!byId.get(oven.id)?.builtIn) byId.set(oven.id, oven); return [...byId.values()].sort( (left, right) => Number(right.builtIn) - Number(left.builtIn) || left.name.localeCompare(right.name), @@ -195,10 +185,8 @@ function resolvePackageInput() { } if (flags.has("dir")) { const dir = resolve(flags.get("dir")); - const instructionsPath = safeStat(join(dir, "instructions.md"))?.isFile() - ? join(dir, "instructions.md") - : join(dir, "definition.md"); - const detailPath = safeStat(join(dir, "detail.json"))?.isFile() ? join(dir, "detail.json") : join(dir, "dashboard.json"); + const instructionsPath = join(dir, "instructions.md"); + const detailPath = join(dir, "detail.json"); pkg.instructions = readTextFileWithLimit(instructionsPath, MAX_INSTRUCTION_BYTES, "Oven instructions"); pkg.detail = JSON.parse(readTextFileWithLimit(detailPath, MAX_DETAIL_BYTES, "Oven detail template")); } @@ -225,40 +213,26 @@ function resolvePackageInput() { return normalized; } -function writeFileAtomic(dir, name, contents) { - const temporary = join(dir, `.${name}.${randomBytes(6).toString("hex")}`); - writeFileSync(temporary, contents); - renameSync(temporary, join(dir, name)); -} - -function persistOven(pkg, { allowReplace }) { +function persistOven(pkg, { allowReplace, sidecar }) { const files = { "instructions.md": `${pkg.instructions}\n`, "detail.json": `${JSON.stringify(pkg.detail, null, 2)}\n`, + ...(sidecar ? { "oven.json": `${JSON.stringify(sidecar, null, 2)}\n` } : {}), }; - const target = join(customOvensDir, pkg.id); - if (existsSync(target)) { - if (!allowReplace) throw new Error(`Oven ${pkg.id} already exists. Use \`oven update ${pkg.id}\` or --force.`); - for (const [name, contents] of Object.entries(files)) writeFileAtomic(target, name, contents); - return target; - } - mkdirSync(customOvensDir, { recursive: true }); - const temporary = join(customOvensDir, `.${pkg.id}.${randomBytes(6).toString("hex")}`); - mkdirSync(temporary); try { - for (const [name, contents] of Object.entries(files)) writeFileSync(join(temporary, name), contents); - renameSync(temporary, target); + return atomicDirectory(customOvensDir, pkg.id, files, { replace: allowReplace, preserveExisting: allowReplace }); } catch (error) { - rmSync(temporary, { recursive: true, force: true }); + if (!allowReplace && error.message === `${pkg.id} already exists.`) { + throw new Error(`Oven ${pkg.id} already exists. Use \`oven update ${pkg.id}\` or --force.`); + } throw error; } - return target; } function assertCustomTarget(id, verb) { const existing = findOven(id); if (existing?.builtIn) { - throw new Error(`Oven ${id} is built-in and read-only. Fork it: \`oven create --dir ${existing.path}\`.`); + throw new Error(`Oven ${id} is built-in and read-only. Fork it: \`oven fork ${id} \`.`); } if (verb === "update" && !existing) throw new Error(`Oven ${id} does not exist. Use \`oven create\` instead.`); } @@ -276,6 +250,7 @@ Usage: burnlist oven create --dir (reads instructions.md + detail.json) burnlist oven create --package (JSON: {name?, instructions, detail}) burnlist oven update [same inputs as create] + burnlist oven fork Options: --name Set the Oven name (owns the level-one heading). @@ -386,6 +361,22 @@ try { process.exit(0); } + if (subcommand === "fork") { + const [sourceId, newId] = positionals; + if (!sourceId || !newId) fail("Usage: burnlist oven fork "); + const source = findOven(sourceId); + if (!source) fail(`Unknown Oven "${sourceId}". Run \`burnlist oven list\`.`); + const pkg = normalizeOvenPackage({ id: ovenId(newId), instructions: source.instructions, detail: source.detail }); + const sourceRevision = ovenRevision(source); + if (findOven(pkg.id)) throw new Error(`Oven ${pkg.id} already exists.`); + const path = persistOven(pkg, { + allowReplace: false, + sidecar: { forkedFrom: { ovenId: source.id, revision: sourceRevision } }, + }); + console.log(`Forked Oven ${pkg.id} at ${path}\nForked from ${source.id}@${sourceRevision}`); + process.exit(0); + } + fail(`Unknown subcommand "${subcommand}". Run \`burnlist oven help\`.`); } catch (error) { fail(error.message); diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index b231cac..4c745cb 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; +import { ovenRevision } from "../ovens/oven-contract.mjs"; const repoRoot = resolve(new URL("../..", import.meta.url).pathname); const binPath = join(repoRoot, "bin", "burnlist.mjs"); @@ -87,3 +88,67 @@ test("oven view reads optional fork lineage and rejects malformed sidecars", () ); } finally { context.cleanup(); } }); + +test("oven create and update swap the package while preserving sibling files", () => { + const context = fixture(); + const ovensDir = join(context.repo, "ovens"); + try { + const packagePath = join(context.repo, "replacement.json"); + writeFileSync(packagePath, JSON.stringify({ + instructions: "# Initial Oven\n\nInitial checklist.", detail: detailFixture(), + })); + run(context, "oven", "create", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); + writeFileSync(join(ovensDir, "sample-oven", "oven.json"), JSON.stringify({ + forkedFrom: { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }, + })); + writeFileSync(packagePath, JSON.stringify({ + instructions: "# Updated Oven\n\nUpdated checklist.", detail: detailFixture(), + })); + run(context, "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); + assert.match(readFileSync(join(ovensDir, "sample-oven", "instructions.md"), "utf8"), /Updated checklist/u); + assert.deepEqual(JSON.parse(readFileSync(join(ovensDir, "sample-oven", "detail.json"), "utf8")), detailFixture()); + assert.equal(JSON.parse(readFileSync(join(ovensDir, "sample-oven", "oven.json"), "utf8")).forkedFrom.ovenId, "source-oven"); + assert.equal(readdirSync(ovensDir).some((name) => name.startsWith(".")), false); + } finally { context.cleanup(); } +}); + +test("oven fork writes source revision lineage and discovery exposes it", () => { + const context = fixture(); + const ovensDir = join(context.repo, "ovens"); + try { + writeOven(ovensDir, "source-oven"); + const source = JSON.parse(run(context, "oven", "view", "source-oven", "--json", "--ovens-dir", ovensDir)); + const expectedRevision = ovenRevision({ instructions: source.instructions, detail: source.detail }); + const output = run(context, "oven", "fork", "source-oven", "forked-oven", "--ovens-dir", ovensDir); + assert.match(output, new RegExp(`Forked from source-oven@${expectedRevision}`)); + assert.deepEqual(JSON.parse(readFileSync(join(ovensDir, "forked-oven", "oven.json"), "utf8")), { + forkedFrom: { ovenId: "source-oven", revision: expectedRevision }, + }); + const forked = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)) + .find((oven) => oven.id === "forked-oven"); + assert.deepEqual(forked.forkedFrom, { ovenId: "source-oven", revision: expectedRevision }); + } finally { context.cleanup(); } +}); + +test("oven ignores legacy files and accepts only the two-file package layout", () => { + const context = fixture(); + const ovensDir = join(context.repo, "ovens"); + const legacyDir = join(ovensDir, "legacy-only"); + try { + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, "definition.md"), "# Legacy Oven\n"); + writeFileSync(join(legacyDir, "dashboard.json"), JSON.stringify(detailFixture())); + writeOven(ovensDir, "two-file-oven"); + const ovens = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)); + assert.equal(ovens.some((oven) => oven.id === "legacy-only"), false); + assert.equal(ovens.some((oven) => oven.id === "two-file-oven"), true); + assert.throws( + () => run(context, "oven", "view", "legacy-only", "--json", "--ovens-dir", ovensDir), + (error) => String(error.stderr).includes('Unknown Oven "legacy-only"'), + ); + assert.throws( + () => run(context, "oven", "create", "copied-legacy", "--dir", legacyDir, "--ovens-dir", ovensDir), + (error) => String(error.stderr).includes("instructions.md"), + ); + } finally { context.cleanup(); } +}); diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 129acee..efee9f5 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; export function readTextFileWithLimit(path, maxBytes, label) { @@ -16,16 +16,30 @@ export function safeStat(path) { } } -export function atomicDirectory(parent, id, files) { +export function atomicDirectory(parent, id, files, { replace = false, preserveExisting = false } = {}) { mkdirSync(parent, { recursive: true }); const temporary = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); const target = join(parent, id); + if (existsSync(target) && !replace) throw new Error(`${id} already exists.`); mkdirSync(temporary); try { + if (preserveExisting && existsSync(target)) cpSync(target, temporary, { recursive: true }); for (const [name, contents] of Object.entries(files)) { writeFileSync(join(temporary, name), contents); } - renameSync(temporary, target); + if (!replace || !existsSync(target)) { + renameSync(temporary, target); + return target; + } + const previous = join(parent, `.${id}.old.${randomBytes(8).toString("hex")}`); + renameSync(target, previous); + try { + renameSync(temporary, target); + } catch (error) { + try { renameSync(previous, target); } catch { /* The caller receives the original failure. */ } + throw error; + } + try { rmSync(previous, { recursive: true, force: true }); } catch { /* Best-effort cleanup. */ } } catch (error) { rmSync(temporary, { recursive: true, force: true }); throw error; From 9f4f4ed96e63098038f0e7606aad8a4eae0ee7ee Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 03:03:12 +0200 Subject: [PATCH 12/30] fix: resolve oven bindings per repo and flag untrusted oven data --- .../DifferentialTesting.tsx | 3 +- dashboard/src/lib/hrefs.ts | 6 ++ dashboard/src/lib/index.ts | 2 +- ...ifferential-testing-data-contract.test.mjs | 3 +- .../engine/differential-testing-handler.mjs | 48 +++++----- .../renderer/differential-testing-renderer.js | 2 + src/ovens/handlers/generic-json-handler.mjs | 2 +- src/server/burnlist-dashboard-server.mjs | 17 ++-- src/server/dashboard-routes.test.mjs | 87 +++++++++++++++++-- src/server/oven-bindings.mjs | 15 +++- src/server/oven-bindings.test.mjs | 18 ++-- src/server/oven-warm.test.mjs | 2 +- 12 files changed, 155 insertions(+), 50 deletions(-) diff --git a/dashboard/src/components/DifferentialTesting/DifferentialTesting.tsx b/dashboard/src/components/DifferentialTesting/DifferentialTesting.tsx index 6250b4a..f3c5a99 100644 --- a/dashboard/src/components/DifferentialTesting/DifferentialTesting.tsx +++ b/dashboard/src/components/DifferentialTesting/DifferentialTesting.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { ovenRepoKey } from "@lib"; // @ts-expect-error The canonical renderer is plain ESM so React and the direct Oven route share one implementation. import { startDifferentialTestingLiveUpdates } from "../../../../ovens/differential-testing/renderer/differential-testing-renderer.js"; @@ -12,7 +13,7 @@ export function DifferentialTestingPage() { useEffect(() => { if (!root.current) return; - const controller = startDifferentialTestingLiveUpdates(root.current); + const controller = startDifferentialTestingLiveUpdates(root.current, { repoKey: ovenRepoKey() }); return () => controller.stop(); }, []); diff --git a/dashboard/src/lib/hrefs.ts b/dashboard/src/lib/hrefs.ts index 7bd9f93..cb640f6 100644 --- a/dashboard/src/lib/hrefs.ts +++ b/dashboard/src/lib/hrefs.ts @@ -7,6 +7,12 @@ export function currentSection() { return "burnlists"; } +export function ovenRepoKey() { + return currentSection() === "differential-testing" + ? new URLSearchParams(window.location.search).get("repoKey") + : null; +} + export function selectedBurnlist(): SelectedBurnlist | null { if (currentSection() !== "burnlists") return null; const plan = new URLSearchParams(window.location.search).get("plan"); diff --git a/dashboard/src/lib/index.ts b/dashboard/src/lib/index.ts index 32ca9ba..94d9d5b 100644 --- a/dashboard/src/lib/index.ts +++ b/dashboard/src/lib/index.ts @@ -1,5 +1,5 @@ export { formatTime } from "./format"; -export { burnlistHref, currentSection, filterFromUrl, listHref, selectedBurnlist } from "./hrefs"; +export { burnlistHref, currentSection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist } from "./hrefs"; export type { Burnlist, ChecklistProgressData, ChecklistItem, CompletedItem, Filter, HistoryPoint, ProgressData, Project, SelectedBurnlist, Warning } from "./types"; export { cn, joinClasses } from "./utils"; export type { ClassValue } from "./utils"; diff --git a/ovens/differential-testing/engine/differential-testing-data-contract.test.mjs b/ovens/differential-testing/engine/differential-testing-data-contract.test.mjs index 72b9dfb..d7cf14c 100644 --- a/ovens/differential-testing/engine/differential-testing-data-contract.test.mjs +++ b/ovens/differential-testing/engine/differential-testing-data-contract.test.mjs @@ -1501,6 +1501,7 @@ test("live Differential Testing dashboard polls and updates only when the payloa return 17; }, clearIntervalImpl: (timer) => { clearedTimer = timer; }, + repoKey: "0123456789ab", mount: (_root, _oven, initialPayload) => { mountedPayloads.push(initialPayload); return { update: (_nextOven, nextPayload) => updatedPayloads.push(nextPayload) }; @@ -1520,7 +1521,7 @@ test("live Differential Testing dashboard polls and updates only when the payloa assert.deepEqual(updatedPayloads, [payload]); payload = { publishedAt: "2026-01-01T12:00:04.000Z" }; await controller.selectScenario("0123456789abcdef"); - assert.equal(requests.at(-1)[0], "/api/oven-data/differential-testing?scenario=0123456789abcdef"); + assert.equal(requests.at(-1)[0], "/api/oven-data/differential-testing?scenario=0123456789abcdef&repoKey=0123456789ab"); assert.deepEqual(updatedPayloads, [{ publishedAt: "2026-01-01T12:00:02.000Z" }, payload]); assert.equal(requests.filter(([url]) => url === "/api/ovens/differential-testing").length, 1); assert.ok(requests.every(([, options]) => options.cache === "no-store")); diff --git a/ovens/differential-testing/engine/differential-testing-handler.mjs b/ovens/differential-testing/engine/differential-testing-handler.mjs index 0189947..f36cab6 100644 --- a/ovens/differential-testing/engine/differential-testing-handler.mjs +++ b/ovens/differential-testing/engine/differential-testing-handler.mjs @@ -14,10 +14,11 @@ import { function differentialTestingIndexCache(ctx, path) { const readPath = resolve(realpathSync(dirname(path)), basename(path)); + const cacheKey = `index:${readPath}`; const stat = safeStat(readPath); if (!stat?.isFile()) throw new Error("configured Differential Testing data is missing"); const signature = `${readPath}\0${stat.ino}\0${stat.size}\0${stat.mtimeMs}`; - const cached = ctx.cache.get("index"); + const cached = ctx.cache.get(cacheKey); if (cached?.signature === signature) return cached; const source = readTextFileWithLimit(readPath, ctx.maxOvenDataBytes, "Oven differential-testing data"); const document = JSON.parse(source); @@ -62,7 +63,7 @@ function differentialTestingIndexCache(ctx, path) { scenarioResponses: new Map(), }; } - ctx.cache.set("index", index); + ctx.cache.set(cacheKey, index); return index; } @@ -178,21 +179,21 @@ export const differentialTestingHandler = Object.freeze({ warmIntervalMs: 1_000, dashboardEntries(ctx) { - const path = ctx.ovenDataBindings.get("differential-testing"); - if (!path) return []; - const index = differentialTestingIndexCache(ctx, path); - const matchedRepo = ctx.discoveredRepos() - .filter((entry) => index.readPath === entry.root || index.readPath.startsWith(`${entry.root}/`)) - .sort((left, right) => right.root.length - left.root.length)[0] ?? null; - const repo = matchedRepo?.name ?? "differential-testing"; - return index.scenarios.map((scenario) => ({ - id: scenario.id, repo, repoRoot: matchedRepo?.root ?? null, title: scenario.label, - 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)}`, - progressLabel: `${scenario.frameCount} frames`, - })); + const bindings = ctx.ovenDataBindings.get("differential-testing") ?? []; + return bindings.flatMap((binding) => { + const index = differentialTestingIndexCache(ctx, binding.path); + const repo = binding.repoKey === null + ? "differential-testing" + : ctx.discoveredRepos().find((entry) => entry.repoKey === binding.repoKey)?.name ?? basename(binding.repoRoot); + return index.scenarios.map((scenario) => ({ + id: scenario.id, repo, repoKey: binding.repoKey, repoRoot: binding.repoRoot, title: scenario.label, + 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)}`}`, + progressLabel: `${scenario.frameCount} frames`, + })); + }); }, serveData(ctx) { @@ -218,12 +219,13 @@ export const differentialTestingHandler = Object.freeze({ }, warm(ctx) { - const path = ctx.ovenDataBindings.get("differential-testing"); - if (!path) return; - try { - differentialTestingIndexCache(ctx, path); - } catch { - // The request path reports validation errors; background warming remains silent. + const paths = new Set((ctx.ovenDataBindings.get("differential-testing") ?? []).map((binding) => binding.path)); + for (const path of paths) { + try { + differentialTestingIndexCache(ctx, path); + } catch { + // The request path reports validation errors; background warming remains silent. + } } }, }); diff --git a/ovens/differential-testing/renderer/differential-testing-renderer.js b/ovens/differential-testing/renderer/differential-testing-renderer.js index 84974ed..8dd5b4c 100644 --- a/ovens/differential-testing/renderer/differential-testing-renderer.js +++ b/ovens/differential-testing/renderer/differential-testing-renderer.js @@ -1792,6 +1792,7 @@ export function startDifferentialTestingLiveUpdates(root, { locationImpl = globalThis.location, historyImpl = globalThis.history, mount = mountDifferentialTestingDashboard, + repoKey = null, refreshMs = DIFFERENTIAL_TESTING_REFRESH_MS, onError = (error, hasDashboard) => { if (!hasDashboard) root.innerHTML = `
${escapeHtml(String(error?.message || error))}
`; @@ -1818,6 +1819,7 @@ export function startDifferentialTestingLiveUpdates(root, { const payloadUrl = (scenarioId) => { const searchParams = new URLSearchParams(); if (scenarioId) searchParams.set("scenario", scenarioId); + if (repoKey) searchParams.set("repoKey", repoKey); if (fieldViewQuery) { searchParams.set("search", fieldViewQuery.search); searchParams.set("filter", fieldViewQuery.filter); diff --git a/src/ovens/handlers/generic-json-handler.mjs b/src/ovens/handlers/generic-json-handler.mjs index d20c636..9cadf70 100644 --- a/src/ovens/handlers/generic-json-handler.mjs +++ b/src/ovens/handlers/generic-json-handler.mjs @@ -8,7 +8,7 @@ export const genericJsonHandler = Object.freeze({ throw Object.assign(new Error(`configured data for Oven ${id} is missing`), { status: 404 }); } const payload = JSON.parse(readTextFileWithLimit(bindingPath, maxOvenDataBytes, `Oven ${id} data`)); - return { ovenId: id, path: bindingPath, payload }; + return { ovenId: id, path: bindingPath, payload, validated: false }; }, dashboardEntries({ id, discoverBurnlists }) { diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 0fbdbd9..38b36b3 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -226,8 +226,15 @@ function candidateRepoRoots() { } function resolvedOvenDataBindings() { - return new Map([...effectiveBindings({ repoRoots: candidateRepoRoots(), override: ovenDataOverrides })] - .map(([id, binding]) => [id, binding.path])); + return effectiveBindings({ repoRoots: candidateRepoRoots(), override: ovenDataOverrides }); +} + +function selectedOvenDataBinding(ovenDataBindings, id, url) { + const bindings = ovenDataBindings.get(id) ?? []; + const repoKeys = url.searchParams.getAll("repoKey"); + if (repoKeys.length > 1) throw Object.assign(new Error("repoKey must be supplied at most once"), { status: 400 }); + if (repoKeys.length === 1) return bindings.find((binding) => binding.repoKey === repoKeys[0]) ?? null; + return bindings.find((binding) => binding.repoKey === null) ?? null; } function burnlistPathsFor(repoRoots) { @@ -980,16 +987,16 @@ const server = createServer(async (req, res) => { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); const id = ovenDataRoute[1]; const handler = getOvenHandler(id); - const bindingPath = ovenDataBindings.get(id); + const binding = selectedOvenDataBinding(ovenDataBindings, id, url); let oven = null; if (!handler) { oven = discoverOvens().find((entry) => entry.id === id) ?? null; if (!oven) return json(res, 404, { validated: false, error: `Oven ${id} is not available` }); } - if (!bindingPath) return json(res, 404, { error: `no data binding configured for Oven ${id}` }); + if (!binding) return json(res, 404, { error: `no data binding configured for Oven ${id}` }); try { const active = handler ?? genericJsonHandler; - const response = active.serveData?.(ovenHandlerContext({ id, oven, req, res, url, bindingPath, ovenDataBindings })); + const response = active.serveData?.(ovenHandlerContext({ id, oven, req, res, url, bindingPath: binding.path, ovenDataBindings })); if (response !== undefined) json(res, 200, response); } catch (error) { json(res, Number.isInteger(error.status) ? error.status : 422, { diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 8499ed1..6864cb9 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -4,7 +4,7 @@ import { createServer, get, request } from "node:http"; import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildPayload } from "../../ovens/differential-testing/example/adapter.mjs"; import { normalizeOvenPackage, ovenRevision } from "../ovens/oven-contract.mjs"; @@ -100,11 +100,15 @@ test("registered Oven routes and dashboard entries ignore malformed custom Oven }, async ({ baseUrl }) => { const checklist = await httpGet(baseUrl, "/api/oven-data/checklist"); assert.equal(checklist.status, 200); - assert.deepEqual(JSON.parse(checklist.body).payload, { source: "generic" }); + const checklistResponse = JSON.parse(checklist.body); + assert.deepEqual(checklistResponse.payload, { source: "generic" }); + assert.equal(checklistResponse.validated, false); const differentialTesting = await httpGet(baseUrl, "/api/oven-data/differential-testing"); assert.equal(differentialTesting.status, 200); - assert.equal(JSON.parse(differentialTesting.body).scenarioId, differentialTestingPayload.scenarioCatalog.selectedScenarioId); + const differentialTestingResponse = JSON.parse(differentialTesting.body); + assert.equal(differentialTestingResponse.scenarioId, differentialTestingPayload.scenarioCatalog.selectedScenarioId); + assert.equal(Object.hasOwn(differentialTestingResponse, "validated"), false); const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; assert.equal(entries.some((entry) => entry.ovenId === "checklist"), true); @@ -124,6 +128,57 @@ test("an unknown Oven with a data binding remains unvalidated", { timeout: 20_00 }); }); +test("a discovered custom Oven with a data binding is served as unvalidated JSON", { timeout: 20_000 }, async () => { + await withServer({ + ovens: [{ id: "custom-oven" }], + ovenData: [{ id: "custom-oven", payload: { source: "custom" } }], + }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, "/api/oven-data/custom-oven"); + assert.equal(response.status, 200); + assert.deepEqual(JSON.parse(response.body).payload, { source: "custom" }); + assert.equal(JSON.parse(response.body).validated, false); + }); +}); + +test("Differential Testing bindings remain distinct for each repository", { timeout: 20_000 }, async () => { + const timestamp = "2026-01-01T12:00:00.000Z"; + const payloadFor = (captureId) => buildPayload( + { + captureId, generatedAt: timestamp, + fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], + samples: [{ tick: 0, values: { position: 1 } }], + }, + { captureId: `${captureId}-candidate`, generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, + ); + const first = payloadFor("first-repo"); + const second = payloadFor("second-repo"); + await withServer({ + burnlists: [{ repoPath: "a/first" }, { repoPath: "b/second" }], + scanRoots: ["a", "b"], + ovenData: [ + { id: "differential-testing", payload: first, repoPath: "a/first", persisted: true, override: false }, + { id: "differential-testing", payload: second, repoPath: "b/second", persisted: true, override: false }, + ], + }, async ({ baseUrl }) => { + const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists + .filter((entry) => entry.ovenId === "differential-testing"); + assert.equal(entries.length, 2); + assert.equal(new Set(entries.map((entry) => entry.repoKey)).size, 2); + for (const entry of entries) assert.match(entry.href, new RegExp(`^/ovens/differential-testing/view\\?scenario=${entry.id}&repoKey=${entry.repoKey}$`, "u")); + + const firstEntry = entries.find((entry) => entry.title === "first-repo"); + const secondEntry = entries.find((entry) => entry.title === "second-repo"); + assert.ok(firstEntry); + assert.ok(secondEntry); + const firstResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${firstEntry.repoKey}`); + const secondResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${secondEntry.repoKey}`); + assert.equal(firstResponse.status, 200); + assert.equal(secondResponse.status, 200); + assert.equal(JSON.parse(firstResponse.body).payload.subtitle, "first-repo / first-repo-candidate"); + assert.equal(JSON.parse(secondResponse.body).payload.subtitle, "second-repo / second-repo-candidate"); + }); +}); + test("Oven discovery exposes optional lineage and rejects malformed sidecars", { timeout: 20_000 }, async () => { const forkedFrom = { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }; await withServer({ @@ -246,14 +301,30 @@ async function withServer({ withBurnlist, burnlists, ovenData = [], ovens = [], writeFile(join(dirname(planPath), "goal.md"), "# Fixture Goal\n\n## Goal\n\nRoute behavior fixture.\n"), ]); })); - await Promise.all(ovenData.map(({ id, payload }) => writeFile( - join(fixtureRoot, `${id}.json`), - JSON.stringify(payload), - ))); + const writtenOvenData = await Promise.all(ovenData.map(async (entry, index) => { + const path = join(fixtureRoot, entry.repoPath ?? "", entry.fileName ?? `${entry.id}-${index}.json`); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(entry.payload)); + return { ...entry, path }; + })); + const persistedBindings = new Map(); + for (const entry of writtenOvenData.filter((candidate) => candidate.persisted)) { + const repoRoot = join(fixtureRoot, entry.repoPath); + const bindings = persistedBindings.get(repoRoot) ?? {}; + bindings[entry.id] = { path: relative(repoRoot, entry.path), boundAt: "2026-07-14T12:00:00.000Z" }; + persistedBindings.set(repoRoot, bindings); + } + await Promise.all([...persistedBindings].map(async ([repoRoot, bindings]) => { + const storePath = join(repoRoot, ".local", "burnlist", "bindings.json"); + await mkdir(dirname(storePath), { recursive: true }); + await writeFile(storePath, JSON.stringify({ schemaVersion: 1, bindings })); + })); await Promise.all(ovens.map((oven) => writeOvenFixture(fixtureRoot, oven))); await Promise.all(runs.map((run) => writeRunFixture(fixtureRoot, run))); const port = await availablePort(); - const ovenDataBindings = ovenData.map(({ id }) => `${id}=${join(fixtureRoot, `${id}.json`)}`).join(","); + const ovenDataBindings = writtenOvenData + .filter((entry) => entry.override !== false) + .map((entry) => `${entry.id}=${entry.path}`).join(","); child = spawn(process.execPath, [ serverPath, "--port", String(port), diff --git a/src/server/oven-bindings.mjs b/src/server/oven-bindings.mjs index 3174399..e4cde47 100644 --- a/src/server/oven-bindings.mjs +++ b/src/server/oven-bindings.mjs @@ -1,9 +1,10 @@ -// Binding precedence: persisted repo-local bindings merge by smallest repo root; -// explicit --oven-data overrides always win. Persisted relative paths resolve per read. +// Persisted bindings remain scoped to their repository; explicit --oven-data +// bindings are global defaults. Persisted relative paths resolve per read. import { randomBytes } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { ovenId } from "../ovens/oven-contract.mjs"; +import { repoKey } from "./registry.mjs"; import { withRepoStateLock } from "./repo-state.mjs"; export const BINDING_SCHEMA_VERSION = 1; @@ -130,9 +131,15 @@ export function effectiveBindings({ repoRoots = [], override = new Map(), statFn const bindings = new Map(); for (const repoRoot of [...new Set(repoRoots)].sort((left, right) => left.localeCompare(right))) { for (const [id, binding] of Object.entries(cachedStore(repoRoot, statFn).bindings)) { - if (!bindings.has(id)) bindings.set(id, { path: resolve(repoRoot, binding.path), repoRoot }); + const entries = bindings.get(id) ?? []; + entries.push({ repoKey: repoKey(repoRoot), repoRoot, path: resolve(repoRoot, binding.path) }); + bindings.set(id, entries); } } - for (const [id, path] of override) bindings.set(id, { path, repoRoot: null }); + for (const [id, path] of override) { + const entries = bindings.get(id) ?? []; + entries.push({ repoKey: null, repoRoot: null, path }); + bindings.set(id, entries); + } return bindings; } diff --git a/src/server/oven-bindings.test.mjs b/src/server/oven-bindings.test.mjs index 4106ba9..6c5c58b 100644 --- a/src/server/oven-bindings.test.mjs +++ b/src/server/oven-bindings.test.mjs @@ -61,7 +61,7 @@ test("effective bindings re-read an atomically replaced store with an identical writeBinding(root, "sample-oven", "first.json", BOUND_AT); utimesSync(bindingStorePath(root), unchangedMtime, unchangedMtime); const first = statSync(bindingStorePath(root)); - assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven").path, resolve(root, "first.json")); + assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven")[0].path, resolve(root, "first.json")); writeBinding(root, "sample-oven", "a-longer-second.json", "2026-07-14T12:01:00.000Z"); utimesSync(bindingStorePath(root), unchangedMtime, unchangedMtime); const second = statSync(bindingStorePath(root)); @@ -69,11 +69,11 @@ test("effective bindings re-read an atomically replaced store with an identical assert.notEqual(second.ino, first.ino); assert.notEqual(second.size, first.size); assert.notEqual(second.ctimeMs, first.ctimeMs); - assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven").path, resolve(root, "a-longer-second.json")); + assert.equal(effectiveBindings({ repoRoots: [root] }).get("sample-oven")[0].path, resolve(root, "a-longer-second.json")); } finally { cleanup(); } }); -test("effective bindings select the smallest repository root and let overrides win", () => { +test("effective bindings retain every repository binding and append global overrides", () => { const { root, cleanup } = fixture(); const first = join(root, "a-repo"); const second = join(root, "b-repo"); @@ -83,11 +83,19 @@ test("effective bindings select the smallest repository root and let overrides w writeBinding(first, "sample-oven", "first.json", BOUND_AT); writeBinding(second, "sample-oven", "second.json", BOUND_AT); const persisted = effectiveBindings({ repoRoots: [second, first] }).get("sample-oven"); - assert.deepEqual(persisted, { path: resolve(first, "first.json"), repoRoot: first }); + assert.deepEqual(persisted.map(({ path, repoRoot }) => ({ path, repoRoot })), [ + { path: resolve(first, "first.json"), repoRoot: first }, + { path: resolve(second, "second.json"), repoRoot: second }, + ]); const overridden = effectiveBindings({ repoRoots: [first, second], override: new Map([["sample-oven", "/temporary/override.json"]]), }).get("sample-oven"); - assert.deepEqual(overridden, { path: "/temporary/override.json", repoRoot: null }); + assert.deepEqual(overridden.map(({ path, repoRoot }) => ({ path, repoRoot })), [ + { path: resolve(first, "first.json"), repoRoot: first }, + { path: resolve(second, "second.json"), repoRoot: second }, + { path: "/temporary/override.json", repoRoot: null }, + ]); + assert.equal(overridden.at(-1).repoKey, null); } finally { cleanup(); } }); diff --git a/src/server/oven-warm.test.mjs b/src/server/oven-warm.test.mjs index bcbaeb8..59b278b 100644 --- a/src/server/oven-warm.test.mjs +++ b/src/server/oven-warm.test.mjs @@ -5,5 +5,5 @@ import { warmOvenHandler } from "./oven-warm.mjs"; test("warm guard swallows binding refresh and warm callback failures", () => { const handler = { id: "sample-oven", warm() { throw new Error("unavailable"); } }; assert.doesNotThrow(() => warmOvenHandler(handler, () => { throw new Error("refresh failed"); }, () => ({}))); - assert.doesNotThrow(() => warmOvenHandler(handler, () => new Map([[handler.id, "/tmp/data.json"]]), () => ({}))); + assert.doesNotThrow(() => warmOvenHandler(handler, () => new Map([[handler.id, [{ path: "/tmp/data.json", repoKey: null, repoRoot: null }]]]), () => ({}))); }); From b4510054d13aae765d7fb35fc7824c3ed9f45564 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 03:06:16 +0200 Subject: [PATCH 13/30] fix: make lifecycle close crash-recoverable --- src/cli/lifecycle-cli.test.mjs | 7 ++- src/cli/lifecycle-moves.mjs | 55 +++++++++++++++--- src/cli/lifecycle-moves.test.mjs | 96 ++++++++++++++++++++++++-------- 3 files changed, 123 insertions(+), 35 deletions(-) diff --git a/src/cli/lifecycle-cli.test.mjs b/src/cli/lifecycle-cli.test.mjs index 08f96ef..ede7177 100644 --- a/src/cli/lifecycle-cli.test.mjs +++ b/src/cli/lifecycle-cli.test.mjs @@ -193,14 +193,15 @@ test("lifecycle moves reject a populated target folder", () => { } }); -test("lifecycle moves reject an empty target folder", () => { +test("lifecycle moves reclaim an empty target folder", () => { const context = fixture(); try { const result = newPlan(context); addActiveItem(result.planPath, context.repo); mkdirSync(lifecycleFolder(context.repo, "ready", result.id), { recursive: true }); - assert.match(runFailure(context, "ready", result.id), /target exists/u); - assert.equal(existsSync(lifecycleFolder(context.repo, "draft", result.id)), true); + assert.match(run(context, "ready", result.id), new RegExp(`${result.id} draft -> ready`)); + assert.equal(existsSync(lifecycleFolder(context.repo, "draft", result.id)), false); + assert.equal(existsSync(lifecycleFolder(context.repo, "ready", result.id)), true); } finally { context.cleanup(); } diff --git a/src/cli/lifecycle-moves.mjs b/src/cli/lifecycle-moves.mjs index a5e176e..f7f1db1 100644 --- a/src/cli/lifecycle-moves.mjs +++ b/src/cli/lifecycle-moves.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { LIFECYCLES, @@ -131,6 +131,40 @@ function appendCompletionDigestIfMissing(plan) { return true; } +function targetExists(id) { + return new Error(`${id}: target exists`); +} + +function reserveTarget(targetDir, id) { + try { + mkdirSync(targetDir); + return; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + let entries; + try { + entries = readdirSync(targetDir); + } catch (error) { + if (error?.code !== "ENOENT") throw targetExists(id); + } + if (entries?.length) throw targetExists(id); + if (entries) { + try { + rmdirSync(targetDir); + } catch (error) { + if (error?.code !== "ENOENT" && error?.code !== "ENOTEMPTY") throw error; + if (error?.code === "ENOTEMPTY") throw targetExists(id); + } + } + try { + mkdirSync(targetDir); + } catch (error) { + if (error?.code === "EEXIST") throw targetExists(id); + throw error; + } +} + export function moveLifecycle({ repoRoot, id, from, to, gate, afterMove }) { assertValidBurnlistId(id); const sourceLifecycle = LIFECYCLES.find((lifecycle) => lifecycle.folder === from); @@ -146,17 +180,12 @@ export function moveLifecycle({ repoRoot, id, from, to, gate, afterMove }) { validateOrThrow(plan); gate(plan); mkdirSync(targetRoot, { recursive: true }); - try { - mkdirSync(targetDir); - } catch (error) { - if (error?.code === "EEXIST") throw new Error(`${id}: target exists`); - throw error; - } + reserveTarget(targetDir, id); try { renameSync(sourceDir, targetDir); } catch (error) { rmSync(targetDir, { recursive: true, force: true }); - if (error?.code === "EEXIST" || error?.code === "ENOTEMPTY") throw new Error(`${id}: target exists`); + if (error?.code === "EEXIST" || error?.code === "ENOTEMPTY") throw targetExists(id); throw error; } try { @@ -193,6 +222,16 @@ export function startLifecycle(repoRoot, id) { } export function closeLifecycle(repoRoot, id) { + assertValidBurnlistId(id); + const inprogressDir = join(repoRoot, "notes", "burnlists", "inprogress", id); + const completedDir = join(repoRoot, "notes", "burnlists", "completed", id); + if (!safeStat(inprogressDir)?.isDirectory() && safeStat(completedDir)?.isDirectory()) { + return withLock(completedDir, () => { + const repaired = appendCompletionDigestIfMissing(parsePlan(join(completedDir, "burnlist.md"))); + console.log(repaired ? `${id} completed (digest repaired)` : `${id} already completed`); + return completedDir; + }); + } return moveLifecycle({ repoRoot, id, diff --git a/src/cli/lifecycle-moves.test.mjs b/src/cli/lifecycle-moves.test.mjs index fdd0657..854f6e0 100644 --- a/src/cli/lifecycle-moves.test.mjs +++ b/src/cli/lifecycle-moves.test.mjs @@ -31,6 +31,32 @@ function writePlan(root, lifecycle, id) { return { dir, planPath }; } +function writeClosablePlan(root, lifecycle, id) { + const result = writePlan(root, lifecycle, id); + writeFileSync(result.planPath, [ + "# Test Burnlist", + "", + "## Active Checklist", + "", + "## Completed", + "- B1 | 2026-07-13T12:00:00+00:00 | Test staged burn", + "", + ].join("\n")); + return result; +} + +function captureConsole(callback) { + const original = console.log; + let output = ""; + console.log = (message) => { output += `${message}\n`; }; + try { + const result = callback(); + return { output, result }; + } finally { + console.log = original; + } +} + test("withLock takes over a dead owner and preserves a replacement owner on release", () => { const context = fixture(); try { @@ -187,16 +213,7 @@ test("close leaves its source unchanged when a populated target prevents the ren const context = fixture(); try { const id = "260713-001"; - const { planPath } = writePlan(context.root, "inprogress", id); - writeFileSync(planPath, [ - "# Test Burnlist", - "", - "## Active Checklist", - "", - "## Completed", - "- B1 | 2026-07-13T12:00:00+00:00 | Test staged burn", - "", - ].join("\n")); + const { planPath } = writeClosablePlan(context.root, "inprogress", id); const before = readFileSync(planPath, "utf8"); const target = folder(context.root, "completed", id); mkdirSync(target, { recursive: true }); @@ -209,24 +226,55 @@ test("close leaves its source unchanged when a populated target prevents the ren } }); -test("close leaves its source unchanged when an empty target already exists", () => { +test("close reclaims an empty stale target reservation", () => { const context = fixture(); try { const id = "260713-001"; - const { planPath } = writePlan(context.root, "inprogress", id); - writeFileSync(planPath, [ - "# Test Burnlist", - "", - "## Active Checklist", - "", - "## Completed", - "- B1 | 2026-07-13T12:00:00+00:00 | Test staged burn", - "", - ].join("\n")); - const before = readFileSync(planPath, "utf8"); + writeClosablePlan(context.root, "inprogress", id); mkdirSync(folder(context.root, "completed", id), { recursive: true }); - assert.throws(() => closeLifecycle(context.root, id), /260713-001: target exists/u); - assert.equal(readFileSync(planPath, "utf8"), before); + closeLifecycle(context.root, id); + assert.equal(existsSync(folder(context.root, "inprogress", id)), false); + assert.equal(readFileSync(join(folder(context.root, "completed", id), "burnlist.md"), "utf8").includes("## Completion Digest"), true); + } finally { + context.cleanup(); + } +}); + +test("close repairs a digest-less burnlist already in completed", () => { + const context = fixture(); + try { + const id = "260713-001"; + const { planPath } = writeClosablePlan(context.root, "completed", id); + const { output } = captureConsole(() => closeLifecycle(context.root, id)); + assert.match(output, /260713-001 completed \(digest repaired\)/u); + assert.equal(readFileSync(planPath, "utf8").includes("## Completion Digest"), true); + } finally { + context.cleanup(); + } +}); + +test("close reports an already-completed burnlist with a digest", () => { + const context = fixture(); + try { + const id = "260713-001"; + const { planPath } = writeClosablePlan(context.root, "completed", id); + writeFileSync(planPath, `${readFileSync(planPath, "utf8")}\n## Completion Digest\n- Complete\n`); + const { output } = captureConsole(() => closeLifecycle(context.root, id)); + assert.match(output, /260713-001 already completed/u); + } finally { + context.cleanup(); + } +}); + +test("close normally moves an in-progress burnlist before writing its digest", () => { + const context = fixture(); + try { + const id = "260713-001"; + writeClosablePlan(context.root, "inprogress", id); + const { output } = captureConsole(() => closeLifecycle(context.root, id)); + assert.match(output, /260713-001 inprogress -> completed/u); + assert.equal(existsSync(folder(context.root, "inprogress", id)), false); + assert.equal(readFileSync(join(folder(context.root, "completed", id), "burnlist.md"), "utf8").includes("## Completion Digest"), true); } finally { context.cleanup(); } From 08773149ffb739fde9052ddfba4f55215d89d1e2 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 03:23:20 +0200 Subject: [PATCH 14/30] fix: safe target reclaim, validated resumable close, binding override fallback --- .github/workflows/publish.yml | 2 +- scripts/verify.mjs | 1 + src/cli/lifecycle-cli.test.mjs | 26 +++++++- src/cli/lifecycle-moves.mjs | 56 ++++++----------- src/cli/lifecycle-moves.test.mjs | 20 ++++++ src/server/burnlist-dashboard-server.mjs | 6 +- src/server/dashboard-routes.test.mjs | 42 +++++++++++++ src/server/fs-safe.mjs | 26 ++++++-- src/server/fs-safe.test.mjs | 78 ++++++++++++++++++++++++ 9 files changed, 212 insertions(+), 45 deletions(-) create mode 100644 src/server/fs-safe.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4c02bac..53c0ff8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -85,7 +85,7 @@ jobs: git add package.json package-lock.json git commit -m "chore(release): ${NEXT_VERSION}" git tag "${NEXT_VERSION}" - git push origin HEAD:main "${NEXT_VERSION}" + git push --atomic origin HEAD:main "${NEXT_VERSION}" - name: Publish to npm run: npm publish diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 211b257..ab38e21 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -432,6 +432,7 @@ run(process.execPath, [ "ovens/differential-testing/engine/differential-testing-transport-server.test.mjs", "src/server/discovery.test.mjs", "src/server/plan-model.test.mjs", + "src/server/fs-safe.test.mjs", "src/cli/lifecycle-cli.test.mjs", "src/cli/lifecycle-moves.test.mjs", "src/cli/registry-cli.test.mjs", diff --git a/src/cli/lifecycle-cli.test.mjs b/src/cli/lifecycle-cli.test.mjs index ede7177..5314019 100644 --- a/src/cli/lifecycle-cli.test.mjs +++ b/src/cli/lifecycle-cli.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -75,6 +75,30 @@ test("new allocates incrementing ids and skips an existing draft reservation", ( } }); +test("concurrent new commands allocate distinct ids", async () => { + const context = fixture(); + try { + const create = () => new Promise((resolve, reject) => { + const child = spawn(process.execPath, [binPath, "new"], { + cwd: context.repo, + env: { ...process.env, HOME: context.home }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + let errors = ""; + child.stdout.on("data", (chunk) => { output += chunk; }); + child.stderr.on("data", (chunk) => { errors += chunk; }); + child.on("error", reject); + child.on("close", (status) => resolve({ status, output, errors })); + }); + const results = await Promise.all([create(), create()]); + assert.deepEqual(results.map((result) => result.status), [0, 0]); + assert.deepEqual(new Set(results.map((result) => result.output.trim().split("\n")[0])).size, 2); + } finally { + context.cleanup(); + } +}); + function addActiveItem(planPath, repo) { writeFileSync(planPath, [ "# Sample Burnlist", diff --git a/src/cli/lifecycle-moves.mjs b/src/cli/lifecycle-moves.mjs index f7f1db1..8142b3d 100644 --- a/src/cli/lifecycle-moves.mjs +++ b/src/cli/lifecycle-moves.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs"; +import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { LIFECYCLES, @@ -135,32 +135,13 @@ function targetExists(id) { return new Error(`${id}: target exists`); } -function reserveTarget(targetDir, id) { +function reclaimEmptyTarget(targetDir, id) { try { - mkdirSync(targetDir); + rmdirSync(targetDir); return; } catch (error) { - if (error?.code !== "EEXIST") throw error; - } - let entries; - try { - entries = readdirSync(targetDir); - } catch (error) { - if (error?.code !== "ENOENT") throw targetExists(id); - } - if (entries?.length) throw targetExists(id); - if (entries) { - try { - rmdirSync(targetDir); - } catch (error) { - if (error?.code !== "ENOENT" && error?.code !== "ENOTEMPTY") throw error; - if (error?.code === "ENOTEMPTY") throw targetExists(id); - } - } - try { - mkdirSync(targetDir); - } catch (error) { - if (error?.code === "EEXIST") throw targetExists(id); + if (error?.code === "ENOENT") return; + if (error?.code === "ENOTEMPTY") throw targetExists(id); throw error; } } @@ -180,14 +161,8 @@ export function moveLifecycle({ repoRoot, id, from, to, gate, afterMove }) { validateOrThrow(plan); gate(plan); mkdirSync(targetRoot, { recursive: true }); - reserveTarget(targetDir, id); - try { - renameSync(sourceDir, targetDir); - } catch (error) { - rmSync(targetDir, { recursive: true, force: true }); - if (error?.code === "EEXIST" || error?.code === "ENOTEMPTY") throw targetExists(id); - throw error; - } + reclaimEmptyTarget(targetDir, id); + renameSync(sourceDir, targetDir); try { afterMove?.(parsePlan(join(targetDir, "burnlist.md"))); } catch (error) { @@ -227,7 +202,10 @@ export function closeLifecycle(repoRoot, id) { const completedDir = join(repoRoot, "notes", "burnlists", "completed", id); if (!safeStat(inprogressDir)?.isDirectory() && safeStat(completedDir)?.isDirectory()) { return withLock(completedDir, () => { - const repaired = appendCompletionDigestIfMissing(parsePlan(join(completedDir, "burnlist.md"))); + const plan = parsePlan(join(completedDir, "burnlist.md")); + validateOrThrow(plan); + assertCloseGate(plan); + const repaired = appendCompletionDigestIfMissing(plan); console.log(repaired ? `${id} completed (digest repaired)` : `${id} already completed`); return completedDir; }); @@ -237,15 +215,17 @@ export function closeLifecycle(repoRoot, id) { id, from: "inprogress", to: "completed", - gate(plan) { - if (plan.items.length || !plan.completed.length) { - throw new Error("not ready to close: active checklist must be empty with completed entries"); - } - }, + gate: assertCloseGate, afterMove: appendCompletionDigestIfMissing, }); } +function assertCloseGate(plan) { + if (plan.items.length || !plan.completed.length) { + throw new Error("not ready to close: active checklist must be empty with completed entries"); + } +} + function activeRange(lines) { const start = lines.findIndex((line) => line.trim() === "## Active Checklist"); if (start < 0) throw new Error("Missing ## Active Checklist section."); diff --git a/src/cli/lifecycle-moves.test.mjs b/src/cli/lifecycle-moves.test.mjs index 854f6e0..bebf888 100644 --- a/src/cli/lifecycle-moves.test.mjs +++ b/src/cli/lifecycle-moves.test.mjs @@ -221,6 +221,7 @@ test("close leaves its source unchanged when a populated target prevents the ren assert.throws(() => closeLifecycle(context.root, id), /260713-001: target exists/u); assert.equal(readFileSync(planPath, "utf8"), before); assert.equal(readFileSync(planPath, "utf8").includes("## Completion Digest"), false); + assert.equal(readFileSync(join(target, "existing"), "utf8"), "keep\n"); } finally { context.cleanup(); } @@ -266,6 +267,25 @@ test("close reports an already-completed burnlist with a digest", () => { } }); +test("resumable close validates and re-applies the close gate before digesting", () => { + const context = fixture(); + try { + const id = "260713-001"; + const { planPath } = writePlan(context.root, "completed", id); + assert.throws( + () => closeLifecycle(context.root, id), + /not ready to close: active checklist must be empty with completed entries/u, + ); + assert.equal(readFileSync(planPath, "utf8").includes("## Completion Digest"), false); + + writeFileSync(planPath, "# Test Burnlist\n\n## Active Checklist\n\n## Completed\n- B1 | not-a-timestamp | Test staged burn\n"); + assert.throws(() => closeLifecycle(context.root, id), /invalid timestamp/u); + assert.equal(readFileSync(planPath, "utf8").includes("## Completion Digest"), false); + } finally { + context.cleanup(); + } +}); + test("close normally moves an in-progress burnlist before writing its digest", () => { const context = fixture(); try { diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 38b36b3..abf7780 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -233,7 +233,11 @@ function selectedOvenDataBinding(ovenDataBindings, id, url) { const bindings = ovenDataBindings.get(id) ?? []; const repoKeys = url.searchParams.getAll("repoKey"); if (repoKeys.length > 1) throw Object.assign(new Error("repoKey must be supplied at most once"), { status: 400 }); - if (repoKeys.length === 1) return bindings.find((binding) => binding.repoKey === repoKeys[0]) ?? null; + if (repoKeys.length === 1) { + return bindings.find((binding) => binding.repoKey === repoKeys[0]) + ?? bindings.find((binding) => binding.repoKey === null) + ?? null; + } return bindings.find((binding) => binding.repoKey === null) ?? null; } diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 6864cb9..bd39ed2 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -179,6 +179,48 @@ test("Differential Testing bindings remain distinct for each repository", { time }); }); +test("Oven data repo binding falls back to the global override", { timeout: 20_000 }, async () => { + const timestamp = "2026-01-01T12:00:00.000Z"; + const payloadFor = (captureId) => buildPayload( + { + captureId, generatedAt: timestamp, + fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], + samples: [{ tick: 0, values: { position: 1 } }], + }, + { captureId: `${captureId}-candidate`, generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, + ); + const override = payloadFor("global-override"); + const exact = payloadFor("exact-repo"); + await withServer({ + burnlists: [ + { repoPath: "a/first", title: "First repository" }, + { repoPath: "b/second", title: "Second repository" }, + ], + scanRoots: ["a", "b"], + ovenData: [ + { id: "differential-testing", payload: override }, + { id: "differential-testing", payload: exact, repoPath: "a/first", persisted: true, override: false }, + ], + }, async ({ baseUrl }) => { + const burnlists = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; + const dtEntries = burnlists.filter((entry) => entry.ovenId === "differential-testing"); + // repoKeys come from the checklist entries (DT entry titles are scenario labels, not repo titles). + const firstChecklist = burnlists.find((entry) => entry.ovenId === "checklist" && entry.title === "First repository"); + const secondChecklist = burnlists.find((entry) => entry.ovenId === "checklist" && entry.title === "Second repository"); + assert.ok(firstChecklist); + assert.ok(secondChecklist); + // a/first has its own persisted binding → a DT row; b/second (unbound) gets no fabricated row. + assert.equal(dtEntries.some((entry) => entry.repoKey === firstChecklist.repoKey), true); + assert.equal(dtEntries.some((entry) => entry.repoKey === secondChecklist.repoKey), false); + const exactResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${firstChecklist.repoKey}`); + const fallbackResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${secondChecklist.repoKey}`); + assert.equal(exactResponse.status, 200); + assert.equal(fallbackResponse.status, 200); + assert.equal(JSON.parse(exactResponse.body).payload.subtitle, "exact-repo / exact-repo-candidate"); + assert.equal(JSON.parse(fallbackResponse.body).payload.subtitle, "global-override / global-override-candidate"); + }); +}); + test("Oven discovery exposes optional lineage and rejects malformed sidecars", { timeout: 20_000 }, async () => { const forkedFrom = { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }; await withServer({ diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index efee9f5..174b5a5 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -20,7 +20,7 @@ export function atomicDirectory(parent, id, files, { replace = false, preserveEx mkdirSync(parent, { recursive: true }); const temporary = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); const target = join(parent, id); - if (existsSync(target) && !replace) throw new Error(`${id} already exists.`); + if (existsSync(target) && !replace) throw Object.assign(new Error(`${id} already exists.`), { code: "EEXIST" }); mkdirSync(temporary); try { if (preserveExisting && existsSync(target)) cpSync(target, temporary, { recursive: true }); @@ -36,12 +36,30 @@ export function atomicDirectory(parent, id, files, { replace = false, preserveEx try { renameSync(temporary, target); } catch (error) { - try { renameSync(previous, target); } catch { /* The caller receives the original failure. */ } + try { + renameSync(previous, target); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Could not update ${id}: publish failed and rollback failed; original remains at ${previous}.`, + ); + } throw error; } - try { rmSync(previous, { recursive: true, force: true }); } catch { /* Best-effort cleanup. */ } + try { + rmSync(previous, { recursive: true, force: true }); + } catch (cleanupError) { + throw new Error(`Updated ${id}, but could not clean up ${previous}: ${cleanupError.message}`, { cause: cleanupError }); + } } catch (error) { - rmSync(temporary, { recursive: true, force: true }); + try { + rmSync(temporary, { recursive: true, force: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Could not update ${id}: cleanup of temporary directory ${temporary} failed.`, + ); + } throw error; } return target; diff --git a/src/server/fs-safe.test.mjs b/src/server/fs-safe.test.mjs new file mode 100644 index 0000000..c165ec3 --- /dev/null +++ b/src/server/fs-safe.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { atomicDirectory } from "./fs-safe.mjs"; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "burnlist-fs-safe-")); + return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +test("atomicDirectory reports EEXIST for a populated collision", () => { + const context = fixture(); + try { + atomicDirectory(context.root, "260713-001", { "burnlist.md": "first\n" }); + assert.throws( + () => atomicDirectory(context.root, "260713-001", { "burnlist.md": "second\n" }), + (error) => error?.code === "EEXIST" && error.message === "260713-001 already exists.", + ); + } finally { + context.cleanup(); + } +}); + +function swapFailure(mode, parent) { + const script = [ + 'import fs, { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";', + 'import { syncBuiltinESMExports } from "node:module";', + 'import { join } from "node:path";', + 'const [parent, mode, moduleUrl] = process.argv.slice(1);', + 'const target = join(parent, "oven");', + 'mkdirSync(target, { recursive: true });', + 'writeFileSync(join(target, "instructions.md"), "original\\n");', + 'const nativeRename = fs.renameSync;', + 'const nativeRm = fs.rmSync;', + 'fs.renameSync = (from, to) => {', + ' if (mode === "rollback" && from.startsWith(join(parent, ".oven.")) && !from.startsWith(join(parent, ".oven.old.")) && to === target) throw new Error("publish blocked");', + ' if (mode === "rollback" && from.startsWith(join(parent, ".oven.old.")) && to === target) throw new Error("rollback blocked");', + ' return nativeRename(from, to);', + '};', + 'fs.rmSync = (path, options) => {', + ' if (mode === "cleanup" && path.startsWith(join(parent, ".oven.old."))) throw new Error("cleanup blocked");', + ' return nativeRm(path, options);', + '};', + 'syncBuiltinESMExports();', + 'const { atomicDirectory } = await import(moduleUrl);', + 'try { atomicDirectory(parent, "oven", { "instructions.md": "updated\\n" }, { replace: true, preserveExisting: true }); }', + 'catch (error) {', + ' process.stdout.write(JSON.stringify({ name: error.name, message: error.message, errors: error.errors?.length ?? 0, old: readdirSync(parent).filter((name) => name.startsWith(".oven.old.")), target: existsSync(target) }));', + '}', + ].join("\n"); + const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script, parent, mode, new URL("./fs-safe.mjs", import.meta.url).href], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +test("atomicDirectory surfaces failed rollback and old-directory cleanup", () => { + const rollback = fixture(); + const cleanup = fixture(); + try { + const rollbackResult = swapFailure("rollback", rollback.root); + assert.equal(rollbackResult.name, "AggregateError"); + assert.equal(rollbackResult.errors, 2); + assert.match(rollbackResult.message, /publish failed and rollback failed/u); + assert.equal(rollbackResult.target, false); + assert.equal(rollbackResult.old.length, 1); + + const cleanupResult = swapFailure("cleanup", cleanup.root); + assert.match(cleanupResult.message, /could not clean up/u); + assert.equal(cleanupResult.target, true); + assert.equal(cleanupResult.old.length, 1); + } finally { + rollback.cleanup(); + cleanup.cleanup(); + } +}); From 2d7e1b7002b00970e255ca7a0e9450b0a8722c6e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 10:59:26 +0200 Subject: [PATCH 15/30] fix: store custom ovens at repo root and lock oven package updates --- src/cli/lifecycle-moves.mjs | 79 +--------------- src/cli/oven-cli.mjs | 86 ++++++++++------- src/cli/oven-cli.test.mjs | 74 ++++++++++++++- src/server/burnlist-dashboard-server.mjs | 74 ++++++++------- src/server/fs-safe.mjs | 114 ++++++++++++++++++++++- 5 files changed, 280 insertions(+), 147 deletions(-) diff --git a/src/cli/lifecycle-moves.mjs b/src/cli/lifecycle-moves.mjs index 8142b3d..100641c 100644 --- a/src/cli/lifecycle-moves.mjs +++ b/src/cli/lifecycle-moves.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { LIFECYCLES, @@ -8,7 +8,9 @@ import { parsePlan, validatePlan, } from "../server/plan-model.mjs"; -import { safeStat } from "../server/fs-safe.mjs"; +import { safeStat, withLock } from "../server/fs-safe.mjs"; + +export { withLock } from "../server/fs-safe.mjs"; function lifecycleRoot(repoRoot, lifecycle) { return join(repoRoot, "notes", "burnlists", lifecycle.folder); @@ -52,79 +54,6 @@ export function findBurnlistDir(repoRoot, id) { return matches[0]; } -function isPositivePid(pid) { - return Number.isInteger(pid) && pid > 0; -} - -function readLock(lockPath) { - try { - return JSON.parse(readFileSync(lockPath, "utf8")); - } catch { - return null; - } -} - -function lockOwner(lockPath) { - const owner = readLock(lockPath); - return isPositivePid(owner?.pid) && typeof owner.token === "string" && owner.token ? owner : null; -} - -function pidIsDead(pid) { - if (!isPositivePid(pid)) return false; - try { - process.kill(pid, 0); - return false; - } catch (error) { - return error?.code === "ESRCH"; - } -} - -export function withLock(dir, fn) { - let lockedDir = dir; - const token = randomBytes(16).toString("hex"); - const lockPath = join(lockedDir, ".lock"); - const temporary = join(lockedDir, `.lock.${token}.tmp`); - const busy = () => new Error(`${basename(dir)} is busy (locked)`); - try { - writeFileSync(temporary, JSON.stringify({ token, pid: process.pid })); - try { - linkSync(temporary, lockPath); - } catch (error) { - if (error?.code !== "EEXIST") throw error; - const owner = lockOwner(lockPath); - if (!owner || !pidIsDead(owner.pid)) throw busy(); - const claim = `${lockPath}.claim.${token}`; - try { - renameSync(lockPath, claim); - } catch (takeoverError) { - if (takeoverError?.code === "ENOENT") throw busy(); - throw takeoverError; - } - try { - rmSync(claim, { force: true }); - linkSync(temporary, lockPath); - } catch (takeoverError) { - if (takeoverError?.code === "EEXIST") throw busy(); - throw takeoverError; - } - } - } finally { - rmSync(temporary, { force: true }); - } - try { - const movedDir = fn({ - retarget(movedDir) { - if (typeof movedDir === "string") lockedDir = movedDir; - }, - }); - if (typeof movedDir === "string") lockedDir = movedDir; - return movedDir; - } finally { - const finalLockPath = join(lockedDir, ".lock"); - if (readLock(finalLockPath)?.token === token) rmSync(finalLockPath, { force: true }); - } -} - function appendCompletionDigestIfMissing(plan) { if (/^##\s+Completion Digest\b/m.test(plan.markdown)) return false; atomicWrite(plan.planPath, `${plan.markdown.replace(/\s*$/u, "")}\n\n${completionDigestMarkdown(plan)}\n`); diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index dd438f7..c1bd97c 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -12,7 +12,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; -import { atomicDirectory } from "../server/fs-safe.mjs"; +import { atomicDirectory, ovenPackageLockRoot, withOvenPackageLock } from "../server/fs-safe.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; @@ -48,16 +48,20 @@ function fail(message) { process.exit(1); } -function bindingRepo() { +function repoRoot() { if (flags.get("repo") === "true") fail("--repo requires a path."); return flags.has("repo") ? resolve(launchCwd, flags.get("repo")) : resolveUmbrella(launchCwd); } +function bindingRepo() { + return repoRoot(); +} + // ── storage locations (mirror the dashboard server) ────────────────────────── const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const builtInOvensDir = resolve(packageRoot, "ovens"); const launchCwd = process.cwd(); -const customOvensDir = resolve(launchCwd, flags.get("ovens-dir") ?? ".local/burnlist/ovens"); +const customOvensDir = resolve(repoRoot(), flags.get("ovens-dir") ?? ".local/burnlist/ovens"); function safeStat(path) { try { @@ -89,43 +93,51 @@ function instructionsDescription(instructions) { function readOvenDir(root, id, builtIn) { const safeId = ovenId(id); - const ovenRoot = join(root, safeId); - const instructionsPath = join(ovenRoot, "instructions.md"); - const detailPath = join(ovenRoot, "detail.json"); - if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; - const ovenPackage = normalizeOvenPackage({ - id: safeId, - instructions: readTextFileWithLimit(instructionsPath, MAX_INSTRUCTION_BYTES, "Oven instructions"), - detail: JSON.parse(readTextFileWithLimit(detailPath, MAX_DETAIL_BYTES, "Oven detail template")), - }); - const lineagePath = join(ovenRoot, "oven.json"); - let forkedFrom; - if (safeStat(lineagePath)?.isFile()) { - try { - forkedFrom = normalizeOvenForkedFrom( - JSON.parse(readTextFileWithLimit(lineagePath, MAX_DETAIL_BYTES, "Oven lineage sidecar")), - ).forkedFrom; - } catch (error) { - throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); + const readPackage = () => { + const ovenRoot = join(root, safeId); + const instructionsPath = join(ovenRoot, "instructions.md"); + const detailPath = join(ovenRoot, "detail.json"); + if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; + const ovenPackage = normalizeOvenPackage({ + id: safeId, + instructions: readTextFileWithLimit(instructionsPath, MAX_INSTRUCTION_BYTES, "Oven instructions"), + detail: JSON.parse(readTextFileWithLimit(detailPath, MAX_DETAIL_BYTES, "Oven detail template")), + }); + const lineagePath = join(ovenRoot, "oven.json"); + let forkedFrom; + if (safeStat(lineagePath)?.isFile()) { + try { + forkedFrom = normalizeOvenForkedFrom( + JSON.parse(readTextFileWithLimit(lineagePath, MAX_DETAIL_BYTES, "Oven lineage sidecar")), + ).forkedFrom; + } catch (error) { + throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); + } } - } - return { - id: ovenPackage.id, - name: instructionsName(ovenPackage.instructions, safeId), - description: instructionsDescription(ovenPackage.instructions), - builtIn, - path: ovenRoot, - instructions: ovenPackage.instructions, - detail: ovenPackage.detail, - ovenRevision: ovenRevision(ovenPackage), - ...(forkedFrom ? { forkedFrom } : {}), + return { + id: ovenPackage.id, + name: instructionsName(ovenPackage.instructions, safeId), + description: instructionsDescription(ovenPackage.instructions), + builtIn, + path: ovenRoot, + instructions: ovenPackage.instructions, + detail: ovenPackage.detail, + ovenRevision: ovenRevision(ovenPackage), + ...(forkedFrom ? { forkedFrom } : {}), + }; }; + return builtIn ? readPackage() : withOvenPackageLock(root, safeId, readPackage, { wait: true }); } function ovensIn(root, builtIn) { if (!safeStat(root)?.isDirectory()) return []; - return readdirSync(root) - .filter((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) + const ids = new Set(readdirSync(root).filter((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id))); + if (!builtIn && safeStat(ovenPackageLockRoot(root))?.isDirectory()) { + for (const id of readdirSync(ovenPackageLockRoot(root))) { + if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) ids.add(id); + } + } + return [...ids] .map((id) => { try { return readOvenDir(root, id, builtIn); @@ -148,7 +160,7 @@ function discoverOvens() { function findOven(id) { const safeId = ovenId(id); - return discoverOvens().find((oven) => oven.id === safeId) ?? null; + return readOvenDir(builtInOvensDir, safeId, true) ?? readOvenDir(customOvensDir, safeId, false); } function printOven(oven) { @@ -220,7 +232,9 @@ function persistOven(pkg, { allowReplace, sidecar }) { ...(sidecar ? { "oven.json": `${JSON.stringify(sidecar, null, 2)}\n` } : {}), }; try { - return atomicDirectory(customOvensDir, pkg.id, files, { replace: allowReplace, preserveExisting: allowReplace }); + return withOvenPackageLock(customOvensDir, pkg.id, () => ( + atomicDirectory(customOvensDir, pkg.id, files, { replace: allowReplace, preserveExisting: allowReplace }) + )); } catch (error) { if (!allowReplace && error.message === `${pkg.id} already exists.`) { throw new Error(`Oven ${pkg.id} already exists. Use \`oven update ${pkg.id}\` or --force.`); diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index 4c745cb..1be01fd 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -20,6 +20,10 @@ function run(context, ...args) { return execFileSync(process.execPath, [binPath, ...args], { cwd: context.repo, encoding: "utf8" }); } +function runFrom(cwd, ...args) { + return execFileSync(process.execPath, [binPath, ...args], { cwd, encoding: "utf8" }); +} + function detailFixture() { return { version: 1, @@ -112,6 +116,74 @@ test("oven create and update swap the package while preserving sibling files", ( } finally { context.cleanup(); } }); +test("oven storage follows the umbrella root when launched from a subdirectory", () => { + const context = fixture(); + const subdirectory = join(context.repo, "work", "nested"); + try { + mkdirSync(join(context.repo, "notes", "burnlists"), { recursive: true }); + mkdirSync(subdirectory, { recursive: true }); + const packagePath = join(context.repo, "package.json"); + writeFileSync(packagePath, JSON.stringify({ + instructions: "# Umbrella Oven\n\nStored with the umbrella.", detail: detailFixture(), + })); + runFrom(subdirectory, "oven", "create", "umbrella-oven", "--package", packagePath); + const ovenPath = join(context.repo, ".local", "burnlist", "ovens", "umbrella-oven"); + assert.match(readFileSync(join(ovenPath, "instructions.md"), "utf8"), /Stored with the umbrella/u); + const ovens = JSON.parse(runFrom(subdirectory, "oven", "list", "--json")); + assert.equal(ovens.some((oven) => oven.id === "umbrella-oven"), true); + } finally { context.cleanup(); } +}); + +test("an oven reader waits for an update swap and receives a complete package", async () => { + const context = fixture(); + const ovensDir = join(context.repo, "ovens"); + try { + const packagePath = join(context.repo, "replacement.json"); + writeFileSync(packagePath, JSON.stringify({ + instructions: "# Initial Oven\n\nInitial checklist.", detail: detailFixture(), + })); + run(context, "oven", "create", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); + writeFileSync(packagePath, JSON.stringify({ + instructions: "# Updated Oven\n\nUpdated checklist.", detail: detailFixture(), + })); + const script = [ + 'import fs from "node:fs";', + 'import { syncBuiltinESMExports } from "node:module";', + 'import { join } from "node:path";', + 'const [bin, ovensDir, target] = process.argv.slice(1, 4);', + 'const rename = fs.renameSync;', + 'fs.renameSync = (from, to) => {', + ' const result = rename(from, to);', + ' if (from === target && to.startsWith(join(ovensDir, ".sample-oven.old."))) {', + ' process.stdout.write("target-hidden\\n");', + ' Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 750);', + ' }', + ' return result;', + '};', + 'syncBuiltinESMExports();', + 'process.argv = [process.argv[0], bin, ...process.argv.slice(4)];', + 'await import(bin);', + ].join("\n"); + const writer = spawn(process.execPath, [ + "--input-type=module", "--eval", script, binPath, ovensDir, join(ovensDir, "sample-oven"), + "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir, + ], { cwd: context.repo }); + const writerStatus = new Promise((resolve) => writer.on("close", resolve)); + await new Promise((resolve, reject) => { + writer.stdout.on("data", (chunk) => { + if (chunk.toString().includes("target-hidden")) resolve(); + }); + writer.on("error", reject); + writer.on("close", (status) => reject(new Error(`update ended before the swap reader ran: ${status}`))); + }); + const reader = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); + assert.match(reader.instructions, /Updated checklist/u); + assert.deepEqual(reader.detail, detailFixture()); + const status = await writerStatus; + assert.equal(status, 0); + } finally { context.cleanup(); } +}); + test("oven fork writes source revision lineage and discovery exposes it", () => { const context = fixture(); const ovensDir = join(context.repo, "ovens"); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index abf7780..6e8eaf0 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -32,7 +32,7 @@ import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; -import { readTextFileWithLimit, safeStat } from "./fs-safe.mjs"; +import { ovenPackageLockRoot, readTextFileWithLimit, safeStat, withOvenPackageLock } from "./fs-safe.mjs"; import { warmOvenHandler } from "./oven-warm.mjs"; import { LIFECYCLES, @@ -365,42 +365,50 @@ function instructionsDescription(instructions) { function readOven(root, id, builtIn) { const safeId = ovenId(id); - const ovenRoot = join(root, safeId); - const instructionsPath = join(ovenRoot, "instructions.md"); - const detailPath = join(ovenRoot, "detail.json"); - if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; - const ovenPackage = normalizeOvenPackage({ - id: safeId, - instructions: readTextFileWithLimit(instructionsPath, 65536, "Oven instructions"), - detail: JSON.parse(readTextFileWithLimit(detailPath, 131072, "Oven detail template")), - }); - const lineagePath = join(ovenRoot, "oven.json"); - let forkedFrom; - if (safeStat(lineagePath)?.isFile()) { - try { - forkedFrom = normalizeOvenForkedFrom( - JSON.parse(readTextFileWithLimit(lineagePath, 131072, "Oven lineage sidecar")), - ).forkedFrom; - } catch (error) { - throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); + const readPackage = () => { + const ovenRoot = join(root, safeId); + const instructionsPath = join(ovenRoot, "instructions.md"); + const detailPath = join(ovenRoot, "detail.json"); + if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; + const ovenPackage = normalizeOvenPackage({ + id: safeId, + instructions: readTextFileWithLimit(instructionsPath, 65536, "Oven instructions"), + detail: JSON.parse(readTextFileWithLimit(detailPath, 131072, "Oven detail template")), + }); + const lineagePath = join(ovenRoot, "oven.json"); + let forkedFrom; + if (safeStat(lineagePath)?.isFile()) { + try { + forkedFrom = normalizeOvenForkedFrom( + JSON.parse(readTextFileWithLimit(lineagePath, 131072, "Oven lineage sidecar")), + ).forkedFrom; + } catch (error) { + throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); + } } - } - return { - id: ovenPackage.id, - name: instructionsName(ovenPackage.instructions, safeId), - description: instructionsDescription(ovenPackage.instructions), - builtIn, - instructions: ovenPackage.instructions, - detail: ovenPackage.detail, - ovenRevision: ovenRevision(ovenPackage), - ...(forkedFrom ? { forkedFrom } : {}), + return { + id: ovenPackage.id, + name: instructionsName(ovenPackage.instructions, safeId), + description: instructionsDescription(ovenPackage.instructions), + builtIn, + instructions: ovenPackage.instructions, + detail: ovenPackage.detail, + ovenRevision: ovenRevision(ovenPackage), + ...(forkedFrom ? { forkedFrom } : {}), + }; }; + return builtIn ? readPackage() : withOvenPackageLock(root, safeId, readPackage, { wait: true }); } function ovensIn(root, builtIn) { if (!safeStat(root)?.isDirectory()) return []; - return readdirSync(root) - .filter((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) + const ids = new Set(readdirSync(root).filter((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id))); + if (!builtIn && safeStat(ovenPackageLockRoot(root))?.isDirectory()) { + for (const id of readdirSync(ovenPackageLockRoot(root))) { + if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) ids.add(id); + } + } + return [...ids] .map((id) => readOven(root, id, builtIn)) .filter(Boolean); } @@ -459,10 +467,10 @@ function createOven(value) { instructions = instructionLines.join("\n"); const detail = normalizeOvenDetail(value.detail); const ovenPackage = normalizeOvenPackage({ id, instructions, detail }); - const path = atomicDirectory(customOvensDir, id, { + const path = withOvenPackageLock(customOvensDir, id, () => atomicDirectory(customOvensDir, id, { "instructions.md": `${ovenPackage.instructions}\n`, "detail.json": `${JSON.stringify(ovenPackage.detail, null, 2)}\n`, - }); + })); return { ...readOven(customOvensDir, id, false), path }; } diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 174b5a5..3b15ec3 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; -import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { cpSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; export function readTextFileWithLimit(path, maxBytes, label) { const stat = statSync(path); @@ -16,6 +16,116 @@ export function safeStat(path) { } } +function isPositivePid(pid) { + return Number.isInteger(pid) && pid > 0; +} + +function readLock(lockPath) { + try { + return JSON.parse(readFileSync(lockPath, "utf8")); + } catch { + return null; + } +} + +function lockOwner(lockPath) { + const owner = readLock(lockPath); + return isPositivePid(owner?.pid) && typeof owner.token === "string" && owner.token ? owner : null; +} + +function pidIsDead(pid) { + if (!isPositivePid(pid)) return false; + try { + process.kill(pid, 0); + return false; + } catch (error) { + return error?.code === "ESRCH"; + } +} + +export function withLock(dir, fn) { + let lockedDir = dir; + const token = randomBytes(16).toString("hex"); + const lockPath = join(lockedDir, ".lock"); + const temporary = join(lockedDir, `.lock.${token}.tmp`); + const busy = () => Object.assign(new Error(`${basename(dir)} is busy (locked)`), { code: "ELOCKED" }); + try { + writeFileSync(temporary, JSON.stringify({ token, pid: process.pid })); + try { + linkSync(temporary, lockPath); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + const owner = lockOwner(lockPath); + if (!owner || !pidIsDead(owner.pid)) throw busy(); + const claim = `${lockPath}.claim.${token}`; + try { + renameSync(lockPath, claim); + } catch (takeoverError) { + if (takeoverError?.code === "ENOENT") throw busy(); + throw takeoverError; + } + try { + rmSync(claim, { force: true }); + linkSync(temporary, lockPath); + } catch (takeoverError) { + if (takeoverError?.code === "EEXIST") throw busy(); + throw takeoverError; + } + } + } finally { + rmSync(temporary, { force: true }); + } + try { + const movedDir = fn({ + retarget(movedDir) { + if (typeof movedDir === "string") lockedDir = movedDir; + }, + }); + if (typeof movedDir === "string") lockedDir = movedDir; + return movedDir; + } finally { + const finalLockPath = join(lockedDir, ".lock"); + if (readLock(finalLockPath)?.token === token) rmSync(finalLockPath, { force: true }); + } +} + +export function ovenPackageLockRoot(root) { + return join(root, ".oven-locks"); +} + +function removeEmptyDirectory(path) { + try { + rmdirSync(path); + } catch (error) { + if (!["ENOENT", "ENOTEMPTY"].includes(error?.code)) throw error; + } +} + +export function withOvenPackageLock(root, id, fn, { wait = false } = {}) { + const lockRoot = ovenPackageLockRoot(root); + const lockDir = join(lockRoot, id); + mkdirSync(lockDir, { recursive: true }); + try { + for (let attempt = 0; attempt < 300; attempt += 1) { + try { + let result; + withLock(lockDir, () => { result = fn(); }); + return result; + } catch (error) { + if (error?.code === "ENOENT" && attempt < 299) { + mkdirSync(lockDir, { recursive: true }); + continue; + } + if (!wait || error?.code !== "ELOCKED" || attempt === 299) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + } + } finally { + removeEmptyDirectory(lockDir); + removeEmptyDirectory(lockRoot); + } +} + export function atomicDirectory(parent, id, files, { replace = false, preserveExisting = false } = {}) { mkdirSync(parent, { recursive: true }); const temporary = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); From 735efd0d31e3dce2bd935e34f75c4225e71e2aea Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 11:15:08 +0200 Subject: [PATCH 16/30] fix: publish custom ovens via atomic symlink pointer with lock-free reads --- src/cli/oven-cli.mjs | 48 ++++----- src/cli/oven-cli.test.mjs | 119 ++++++++++++++++------- src/server/burnlist-dashboard-server.mjs | 52 +++++----- src/server/fs-safe.mjs | 106 +++++++++++++++++++- 4 files changed, 237 insertions(+), 88 deletions(-) diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index c1bd97c..7b0d569 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -7,18 +7,17 @@ // 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 { readFileSync, readdirSync, statSync } from "node:fs"; +import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; -import { atomicDirectory, ovenPackageLockRoot, withOvenPackageLock } from "../server/fs-safe.mjs"; +import { atomicOvenPackage, withOvenPackageLock } from "../server/fs-safe.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; const MAX_INSTRUCTION_BYTES = 65536; const MAX_DETAIL_BYTES = 131072; - // ── argv ──────────────────────────────────────────────────────────────────── // process.argv is [node, bin/burnlist.mjs, "oven", , ...rest]. const tokens = process.argv.slice(2); @@ -93,8 +92,14 @@ function instructionsDescription(instructions) { function readOvenDir(root, id, builtIn) { const safeId = ovenId(id); - const readPackage = () => { - const ovenRoot = join(root, safeId); + let ovenRoot; + try { + ovenRoot = realpathSync(join(root, safeId)); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } + try { const instructionsPath = join(ovenRoot, "instructions.md"); const detailPath = join(ovenRoot, "detail.json"); if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; @@ -125,27 +130,24 @@ function readOvenDir(root, id, builtIn) { ovenRevision: ovenRevision(ovenPackage), ...(forkedFrom ? { forkedFrom } : {}), }; - }; - return builtIn ? readPackage() : withOvenPackageLock(root, safeId, readPackage, { wait: true }); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } } function ovensIn(root, builtIn) { - if (!safeStat(root)?.isDirectory()) return []; - const ids = new Set(readdirSync(root).filter((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id))); - if (!builtIn && safeStat(ovenPackageLockRoot(root))?.isDirectory()) { - for (const id of readdirSync(ovenPackageLockRoot(root))) { - if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) ids.add(id); - } + let entries; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; } - return [...ids] - .map((id) => { - try { - return readOvenDir(root, id, builtIn); - } catch (error) { - if (safeStat(join(root, id, "oven.json"))?.isFile()) throw error; - return null; - } - }) + return entries + .map((entry) => entry.name) + .filter((id) => !id.startsWith(".") && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) + .map((id) => readOvenDir(root, id, builtIn)) .filter(Boolean); } @@ -233,7 +235,7 @@ function persistOven(pkg, { allowReplace, sidecar }) { }; try { return withOvenPackageLock(customOvensDir, pkg.id, () => ( - atomicDirectory(customOvensDir, pkg.id, files, { replace: allowReplace, preserveExisting: allowReplace }) + atomicOvenPackage(customOvensDir, pkg.id, files, { replace: allowReplace }) )); } catch (error) { if (!allowReplace && error.message === `${pkg.id} already exists.`) { diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index 1be01fd..da30097 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -53,6 +53,35 @@ function writeOven(root, id, ovenJson) { if (ovenJson !== undefined) writeFileSync(join(ovenRoot, "oven.json"), ovenJson); } +function pausedUpdate(context, ovensDir, packagePath) { + const script = [ + 'import fs, { readFileSync } from "node:fs";', + 'import { syncBuiltinESMExports } from "node:module";', + 'import { join } from "node:path";', + 'const [bin, root] = process.argv.slice(1, 3);', + 'const symlink = fs.symlinkSync;', + 'fs.symlinkSync = (target, path, ...rest) => {', + ' if (path.startsWith(join(root, ".sample-oven.link."))) { process.stdout.write("staged\\n"); readFileSync(0, "utf8"); }', + ' return symlink(target, path, ...rest);', + '};', + 'syncBuiltinESMExports();', + 'process.argv = [process.argv[0], bin, ...process.argv.slice(3)];', + 'await import(bin);', + ].join("\n"); + return spawn(process.execPath, [ + "--input-type=module", "--eval", script, binPath, ovensDir, + "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir, + ], { cwd: context.repo, stdio: ["pipe", "pipe", "pipe"] }); +} + +function waitForBarrier(child) { + return new Promise((resolve, reject) => { + child.stdout.on("data", (chunk) => { if (chunk.toString().includes("staged")) resolve(); }); + child.on("error", reject); + child.on("close", (status) => reject(new Error(`update ended before reaching its publish barrier: ${status}`))); + }); +} + test("oven bind, bindings, and unbind persist a logical repo-local binding", () => { const context = fixture(); const logicalPath = "../generated/current.json"; @@ -112,7 +141,24 @@ test("oven create and update swap the package while preserving sibling files", ( assert.match(readFileSync(join(ovensDir, "sample-oven", "instructions.md"), "utf8"), /Updated checklist/u); assert.deepEqual(JSON.parse(readFileSync(join(ovensDir, "sample-oven", "detail.json"), "utf8")), detailFixture()); assert.equal(JSON.parse(readFileSync(join(ovensDir, "sample-oven", "oven.json"), "utf8")).forkedFrom.ovenId, "source-oven"); - assert.equal(readdirSync(ovensDir).some((name) => name.startsWith(".")), false); + assert.equal(readdirSync(ovensDir).filter((name) => name.startsWith(".")).length, 1); + } finally { context.cleanup(); } +}); + +test("oven update migrates a legacy plain directory to a symlink package", () => { + const context = fixture(); + const ovensDir = join(context.repo, "ovens"); + try { + writeOven(ovensDir, "legacy-oven", JSON.stringify({ + forkedFrom: { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }, + })); + const packagePath = join(context.repo, "replacement.json"); + writeFileSync(packagePath, JSON.stringify({ instructions: "# Updated Oven\n\nMigrated safely.", detail: detailFixture() })); + run(context, "oven", "update", "legacy-oven", "--package", packagePath, "--ovens-dir", ovensDir); + assert.equal(lstatSync(join(ovensDir, "legacy-oven")).isSymbolicLink(), true); + const oven = JSON.parse(run(context, "oven", "view", "legacy-oven", "--json", "--ovens-dir", ovensDir)); + assert.match(oven.instructions, /Migrated safely/u); + assert.equal(oven.forkedFrom.ovenId, "source-oven"); } finally { context.cleanup(); } }); @@ -134,7 +180,7 @@ test("oven storage follows the umbrella root when launched from a subdirectory", } finally { context.cleanup(); } }); -test("an oven reader waits for an update swap and receives a complete package", async () => { +test("oven view and list read complete packages on both sides of a publish barrier", async () => { const context = fixture(); const ovensDir = join(context.repo, "ovens"); try { @@ -146,41 +192,44 @@ test("an oven reader waits for an update swap and receives a complete package", writeFileSync(packagePath, JSON.stringify({ instructions: "# Updated Oven\n\nUpdated checklist.", detail: detailFixture(), })); - const script = [ - 'import fs from "node:fs";', - 'import { syncBuiltinESMExports } from "node:module";', - 'import { join } from "node:path";', - 'const [bin, ovensDir, target] = process.argv.slice(1, 4);', - 'const rename = fs.renameSync;', - 'fs.renameSync = (from, to) => {', - ' const result = rename(from, to);', - ' if (from === target && to.startsWith(join(ovensDir, ".sample-oven.old."))) {', - ' process.stdout.write("target-hidden\\n");', - ' Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 750);', - ' }', - ' return result;', - '};', - 'syncBuiltinESMExports();', - 'process.argv = [process.argv[0], bin, ...process.argv.slice(4)];', - 'await import(bin);', - ].join("\n"); - const writer = spawn(process.execPath, [ - "--input-type=module", "--eval", script, binPath, ovensDir, join(ovensDir, "sample-oven"), - "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir, - ], { cwd: context.repo }); + const writer = pausedUpdate(context, ovensDir, packagePath); const writerStatus = new Promise((resolve) => writer.on("close", resolve)); - await new Promise((resolve, reject) => { - writer.stdout.on("data", (chunk) => { - if (chunk.toString().includes("target-hidden")) resolve(); - }); - writer.on("error", reject); - writer.on("close", (status) => reject(new Error(`update ended before the swap reader ran: ${status}`))); - }); - const reader = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); - assert.match(reader.instructions, /Updated checklist/u); - assert.deepEqual(reader.detail, detailFixture()); + await waitForBarrier(writer); + const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); + const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); + assert.match(oldView.instructions, /Initial checklist/u); + assert.equal(oldList.name, "Initial Oven"); + writer.stdin.end("publish\n"); const status = await writerStatus; assert.equal(status, 0); + const newView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); + const newList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); + assert.match(newView.instructions, /Updated checklist/u); + assert.equal(newList.name, "Updated Oven"); + } finally { context.cleanup(); } +}); + +test("a killed writer leaves the prior symlink package readable and cleans its staged revision", async () => { + const context = fixture(); + const ovensDir = join(context.repo, "ovens"); + try { + const packagePath = join(context.repo, "replacement.json"); + writeFileSync(packagePath, JSON.stringify({ instructions: "# Initial Oven\n\nInitial checklist.", detail: detailFixture() })); + run(context, "oven", "create", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); + writeFileSync(packagePath, JSON.stringify({ instructions: "# Interrupted Oven\n\nNever published.", detail: detailFixture() })); + const writer = pausedUpdate(context, ovensDir, packagePath); + await waitForBarrier(writer); + writer.kill("SIGKILL"); + await new Promise((resolve) => writer.on("close", resolve)); + const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); + const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); + assert.match(oldView.instructions, /Initial checklist/u); + assert.equal(oldList.name, "Initial Oven"); + writeFileSync(packagePath, JSON.stringify({ instructions: "# Recovered Oven\n\nPublished after recovery.", detail: detailFixture() })); + run(context, "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); + assert.match(JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)).instructions, /Published after recovery/u); + assert.equal(lstatSync(join(ovensDir, "sample-oven")).isSymbolicLink(), true); + assert.equal(readdirSync(ovensDir).filter((name) => name.startsWith(".sample-oven.")).length, 1); } finally { context.cleanup(); } }); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 6e8eaf0..324c50d 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -32,7 +32,7 @@ import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; -import { ovenPackageLockRoot, readTextFileWithLimit, safeStat, withOvenPackageLock } from "./fs-safe.mjs"; +import { atomicDirectory, atomicOvenPackage, readTextFileWithLimit, safeStat, withOvenPackageLock } from "./fs-safe.mjs"; import { warmOvenHandler } from "./oven-warm.mjs"; import { LIFECYCLES, @@ -365,8 +365,14 @@ function instructionsDescription(instructions) { function readOven(root, id, builtIn) { const safeId = ovenId(id); - const readPackage = () => { - const ovenRoot = join(root, safeId); + let ovenRoot; + try { + ovenRoot = realpathSync(join(root, safeId)); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } + try { const instructionsPath = join(ovenRoot, "instructions.md"); const detailPath = join(ovenRoot, "detail.json"); if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; @@ -396,19 +402,23 @@ function readOven(root, id, builtIn) { ovenRevision: ovenRevision(ovenPackage), ...(forkedFrom ? { forkedFrom } : {}), }; - }; - return builtIn ? readPackage() : withOvenPackageLock(root, safeId, readPackage, { wait: true }); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } } function ovensIn(root, builtIn) { - if (!safeStat(root)?.isDirectory()) return []; - const ids = new Set(readdirSync(root).filter((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id))); - if (!builtIn && safeStat(ovenPackageLockRoot(root))?.isDirectory()) { - for (const id of readdirSync(ovenPackageLockRoot(root))) { - if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) ids.add(id); - } + let entries; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; } - return [...ids] + return entries + .map((entry) => entry.name) + .filter((id) => !id.startsWith(".") && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) .map((id) => readOven(root, id, builtIn)) .filter(Boolean); } @@ -438,22 +448,6 @@ function ovenSummary(oven) { }; } -function atomicDirectory(parent, id, files) { - mkdirSync(parent, { recursive: true }); - const target = join(parent, id); - if (existsSync(target)) throw new Error(`${id} already exists.`); - const temporary = join(parent, `.${id}.${randomBytes(6).toString("hex")}`); - mkdirSync(temporary); - try { - for (const [name, contents] of Object.entries(files)) writeFileSync(join(temporary, name), contents); - renameSync(temporary, target); - } catch (error) { - rmSync(temporary, { recursive: true, force: true }); - throw error; - } - return target; -} - function createOven(value) { assertKnownKeys(value, new Set(["id", "name", "instructions", "detail"]), "Oven"); const id = ovenId(value.id); @@ -467,7 +461,7 @@ function createOven(value) { instructions = instructionLines.join("\n"); const detail = normalizeOvenDetail(value.detail); const ovenPackage = normalizeOvenPackage({ id, instructions, detail }); - const path = withOvenPackageLock(customOvensDir, id, () => atomicDirectory(customOvensDir, id, { + const path = withOvenPackageLock(customOvensDir, id, () => atomicOvenPackage(customOvensDir, id, { "instructions.md": `${ovenPackage.instructions}\n`, "detail.json": `${JSON.stringify(ovenPackage.detail, null, 2)}\n`, })); diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 3b15ec3..83aa445 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { cpSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; export function readTextFileWithLimit(path, maxBytes, label) { @@ -174,3 +174,107 @@ export function atomicDirectory(parent, id, files, { replace = false, preserveEx } return target; } + +function missing(error) { + return error?.code === "ENOENT"; +} + +function entryAt(path) { + try { + return lstatSync(path); + } catch (error) { + if (missing(error)) return null; + throw error; + } +} + +function cleanupError(errors, message) { + if (errors.length === 1) return new Error(`${message}: ${errors[0].message}`, { cause: errors[0] }); + return new AggregateError(errors, message); +} + +function publishPackageLink(parent, id, revisionDir, target) { + const temporary = join(parent, `.${id}.link.${randomBytes(8).toString("hex")}`); + try { + symlinkSync(basename(revisionDir), temporary); + renameSync(temporary, target); + } catch (error) { + try { + rmSync(temporary, { force: true }); + } catch (cleanupFailure) { + throw cleanupError([error, cleanupFailure], `Could not publish ${id}`); + } + throw error; + } +} + +function migrateLegacyPackage(parent, id, target) { + const revisionDir = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); + renameSync(target, revisionDir); + try { + publishPackageLink(parent, id, revisionDir, target); + } catch (error) { + try { + renameSync(revisionDir, target); + } catch (rollbackFailure) { + throw cleanupError([error, rollbackFailure], `Could not migrate legacy Oven ${id}; original remains at ${revisionDir}`); + } + throw error; + } + return revisionDir; +} + +function cleanupOldPackageDirs(parent, id, current, previous) { + const errors = []; + const prefix = `.${id}.`; + let currentMtime; + try { + currentMtime = statSync(current).mtimeMs; + } catch (error) { + throw cleanupError([error], `Published ${id}, but could not inspect its revision directory`); + } + try { + for (const entry of readdirSync(parent, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === basename(current) || !entry.name.startsWith(prefix)) continue; + const candidate = join(parent, entry.name); + try { + if (candidate === previous || statSync(candidate).mtimeMs <= currentMtime) rmSync(candidate, { recursive: true, force: true }); + } catch (error) { + if (!missing(error)) errors.push(error); + } + } + } catch (error) { + if (!missing(error)) errors.push(error); + } + if (errors.length) throw cleanupError(errors, `Published ${id}, but could not clean up old Oven revisions`); +} + +// Custom Oven packages are immutable revision directories published through a +// symlink pointer. The pointer rename leaves readers either on the old complete +// package or the new complete package; readers never need the writer lock. +export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { + mkdirSync(parent, { recursive: true }); + const target = join(parent, id); + const existing = entryAt(target); + if (existing && !replace) throw Object.assign(new Error(`${id} already exists.`), { code: "EEXIST" }); + + let previous = null; + if (existing) previous = realpathSync(target); + const revisionDir = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); + mkdirSync(revisionDir); + try { + if (previous) cpSync(previous, revisionDir, { recursive: true }); + for (const [name, contents] of Object.entries(files)) writeFileSync(join(revisionDir, name), contents); + if (existing?.isDirectory() && !existing.isSymbolicLink()) previous = migrateLegacyPackage(parent, id, target); + publishPackageLink(parent, id, revisionDir, target); + } catch (error) { + try { + rmSync(revisionDir, { recursive: true, force: true }); + } catch (cleanupFailure) { + throw cleanupError([error, cleanupFailure], `Could not publish Oven ${id}`); + } + throw error; + } + cleanupOldPackageDirs(parent, id, revisionDir, previous); + return target; +} From f368503ffec1d08ab0db54ade4743bda1f1b3dd8 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 11:24:34 +0200 Subject: [PATCH 17/30] fix: retain reader revisions, self-heal oven migration, junction on windows --- src/cli/oven-cli.test.mjs | 4 +-- src/server/fs-safe.mjs | 62 +++++++++++++++++++++++++++++-------- src/server/fs-safe.test.mjs | 61 ++++++++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index da30097..a7b6882 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -141,7 +141,7 @@ test("oven create and update swap the package while preserving sibling files", ( assert.match(readFileSync(join(ovensDir, "sample-oven", "instructions.md"), "utf8"), /Updated checklist/u); assert.deepEqual(JSON.parse(readFileSync(join(ovensDir, "sample-oven", "detail.json"), "utf8")), detailFixture()); assert.equal(JSON.parse(readFileSync(join(ovensDir, "sample-oven", "oven.json"), "utf8")).forkedFrom.ovenId, "source-oven"); - assert.equal(readdirSync(ovensDir).filter((name) => name.startsWith(".")).length, 1); + assert.equal(readdirSync(ovensDir).filter((name) => name.startsWith(".")).length, 2); } finally { context.cleanup(); } }); @@ -229,7 +229,7 @@ test("a killed writer leaves the prior symlink package readable and cleans its s run(context, "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); assert.match(JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)).instructions, /Published after recovery/u); assert.equal(lstatSync(join(ovensDir, "sample-oven")).isSymbolicLink(), true); - assert.equal(readdirSync(ovensDir).filter((name) => name.startsWith(".sample-oven.")).length, 1); + assert.equal(readdirSync(ovensDir).filter((name) => name.startsWith(".sample-oven.")).length, 2); } finally { context.cleanup(); } }); diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 83aa445..6e51d14 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import { cpSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; -import { basename, join } from "node:path"; +import { basename, join, resolve } from "node:path"; export function readTextFileWithLimit(path, maxBytes, label) { const stat = statSync(path); @@ -196,7 +196,8 @@ function cleanupError(errors, message) { function publishPackageLink(parent, id, revisionDir, target) { const temporary = join(parent, `.${id}.link.${randomBytes(8).toString("hex")}`); try { - symlinkSync(basename(revisionDir), temporary); + if (process.platform === "win32") symlinkSync(resolve(revisionDir), temporary, "junction"); + else symlinkSync(basename(revisionDir), temporary, "dir"); renameSync(temporary, target); } catch (error) { try { @@ -210,35 +211,68 @@ function publishPackageLink(parent, id, revisionDir, target) { function migrateLegacyPackage(parent, id, target) { const revisionDir = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); - renameSync(target, revisionDir); + const migrating = join(parent, `.${id}.migrating.${randomBytes(8).toString("hex")}`); + cpSync(target, revisionDir, { recursive: true }); + renameSync(target, migrating); try { publishPackageLink(parent, id, revisionDir, target); } catch (error) { try { - renameSync(revisionDir, target); + renameSync(migrating, target); } catch (rollbackFailure) { - throw cleanupError([error, rollbackFailure], `Could not migrate legacy Oven ${id}; original remains at ${revisionDir}`); + throw cleanupError([error, rollbackFailure], `Could not migrate legacy Oven ${id}; original remains at ${migrating}`); } throw error; } + rmSync(migrating, { recursive: true, force: true }); return revisionDir; } -function cleanupOldPackageDirs(parent, id, current, previous) { - const errors = []; +function packageRevisionDirs(parent, id, requiredFiles) { const prefix = `.${id}.`; - let currentMtime; try { - currentMtime = statSync(current).mtimeMs; + return readdirSync(parent, { withFileTypes: true }) + .filter((entry) => ( + entry.isDirectory() + && entry.name.startsWith(prefix) + && !entry.name.startsWith(`${prefix}migrating.`) + && requiredFiles.every((name) => existsSync(join(parent, entry.name, name))) + )) + .map((entry) => join(parent, entry.name)); } catch (error) { - throw cleanupError([error], `Published ${id}, but could not inspect its revision directory`); + if (missing(error)) return []; + throw error; } +} + +function recoverPackagePublish(parent, id, target, requiredFiles) { + if (entryAt(target)) return; + const prefix = `.${id}.migrating.`; + let migrating; + try { + migrating = readdirSync(parent, { withFileTypes: true }) + .find((entry) => entry.isDirectory() && entry.name.startsWith(prefix)); + } catch (error) { + if (missing(error)) return; + throw error; + } + if (migrating) { + renameSync(join(parent, migrating.name), target); + return; + } + const revisions = packageRevisionDirs(parent, id, requiredFiles); + if (revisions.length) publishPackageLink(parent, id, revisions[revisions.length - 1], target); +} + +function cleanupOldPackageDirs(parent, id, current, previous) { + const errors = []; + const prefix = `.${id}.`; try { for (const entry of readdirSync(parent, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === basename(current) || !entry.name.startsWith(prefix)) continue; const candidate = join(parent, entry.name); try { - if (candidate === previous || statSync(candidate).mtimeMs <= currentMtime) rmSync(candidate, { recursive: true, force: true }); + if (!previous || entry.name !== basename(previous)) rmSync(candidate, { recursive: true, force: true }); } catch (error) { if (!missing(error)) errors.push(error); } @@ -255,17 +289,19 @@ function cleanupOldPackageDirs(parent, id, current, previous) { export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { mkdirSync(parent, { recursive: true }); const target = join(parent, id); + recoverPackagePublish(parent, id, target, Object.keys(files)); const existing = entryAt(target); if (existing && !replace) throw Object.assign(new Error(`${id} already exists.`), { code: "EEXIST" }); let previous = null; - if (existing) previous = realpathSync(target); + if (existing?.isDirectory() && !existing.isSymbolicLink()) { + previous = migrateLegacyPackage(parent, id, target); + } else if (existing) previous = realpathSync(target); const revisionDir = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); mkdirSync(revisionDir); try { if (previous) cpSync(previous, revisionDir, { recursive: true }); for (const [name, contents] of Object.entries(files)) writeFileSync(join(revisionDir, name), contents); - if (existing?.isDirectory() && !existing.isSymbolicLink()) previous = migrateLegacyPackage(parent, id, target); publishPackageLink(parent, id, revisionDir, target); } catch (error) { try { diff --git a/src/server/fs-safe.test.mjs b/src/server/fs-safe.test.mjs index c165ec3..9b4419f 100644 --- a/src/server/fs-safe.test.mjs +++ b/src/server/fs-safe.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import test from "node:test"; -import { atomicDirectory } from "./fs-safe.mjs"; +import { atomicDirectory, atomicOvenPackage, withOvenPackageLock } from "./fs-safe.mjs"; function fixture() { const root = mkdtempSync(join(tmpdir(), "burnlist-fs-safe-")); @@ -76,3 +76,58 @@ test("atomicDirectory surfaces failed rollback and old-directory cleanup", () => cleanup.cleanup(); } }); + +function ovenFiles(version) { + return { + "instructions.md": `# Oven ${version}\n`, + "detail.json": `{ "version": "${version}" }\n`, + "oven.json": `{ "source": "${version}" }\n`, + }; +} + +test("Oven package swaps retain a resolved reader revision and only the newest two", () => { + const context = fixture(); + const target = join(context.root, "oven"); + try { + withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("one"))); + const oldRevision = readFileSync(join(target, "instructions.md"), "utf8"); + const oldPath = realpathSync(target); + withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("two"), { replace: true })); + + assert.equal(oldRevision, "# Oven one\n"); + for (const [name, contents] of Object.entries(ovenFiles("one"))) { + assert.equal(readFileSync(join(oldPath, name), "utf8"), contents); + } + + withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("three"), { replace: true })); + const revisions = readdirSync(context.root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith(".oven.")) + .map((entry) => entry.name); + assert.equal(revisions.length, 2); + assert.equal(revisions.includes(basename(oldPath)), false); + } finally { + context.cleanup(); + } +}); + +test("a crashed legacy Oven migration self-heals before the next publish", () => { + const context = fixture(); + const target = join(context.root, "oven"); + const migration = join(context.root, ".oven.migrating.crashed"); + try { + atomicDirectory(context.root, "oven", { ...ovenFiles("legacy"), "extra.txt": "preserved\n" }); + cpSync(target, join(context.root, ".oven.revision-before-crash"), { recursive: true }); + renameSync(target, migration); + + withOvenPackageLock(context.root, "oven", () => ( + atomicOvenPackage(context.root, "oven", ovenFiles("updated"), { replace: true }) + )); + + assert.equal(lstatSync(target).isSymbolicLink(), true); + assert.equal(readFileSync(join(target, "instructions.md"), "utf8"), "# Oven updated\n"); + assert.equal(readFileSync(join(target, "extra.txt"), "utf8"), "preserved\n"); + assert.equal(existsSync(migration), false); + } finally { + context.cleanup(); + } +}); From 4c80b626c8b31e9cbafb4ea84fd88ae6f7b2bf91 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 13:23:20 +0200 Subject: [PATCH 18/30] fix: publish custom ovens via atomic pointer file for cross-platform safety --- src/cli/oven-cli.mjs | 4 +- src/cli/oven-cli.test.mjs | 38 +++--- src/server/burnlist-dashboard-server.mjs | 4 +- src/server/fs-safe.mjs | 158 +++++++++++------------ src/server/fs-safe.test.mjs | 72 +++++++---- 5 files changed, 151 insertions(+), 125 deletions(-) diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index 7b0d569..f409f88 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -12,7 +12,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; -import { atomicOvenPackage, withOvenPackageLock } from "../server/fs-safe.mjs"; +import { atomicOvenPackage, resolveOvenPackageDir, withOvenPackageLock } from "../server/fs-safe.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; @@ -94,7 +94,7 @@ function readOvenDir(root, id, builtIn) { const safeId = ovenId(id); let ovenRoot; try { - ovenRoot = realpathSync(join(root, safeId)); + ovenRoot = resolveOvenPackageDir(realpathSync(join(root, safeId))); } catch (error) { if (error?.code === "ENOENT") return null; throw error; diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index a7b6882..0a7b96b 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; -import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; import { ovenRevision } from "../ovens/oven-contract.mjs"; +import { resolveOvenPackageDir } from "../server/fs-safe.mjs"; const repoRoot = resolve(new URL("../..", import.meta.url).pathname); const binPath = join(repoRoot, "bin", "burnlist.mjs"); @@ -59,10 +60,10 @@ function pausedUpdate(context, ovensDir, packagePath) { 'import { syncBuiltinESMExports } from "node:module";', 'import { join } from "node:path";', 'const [bin, root] = process.argv.slice(1, 3);', - 'const symlink = fs.symlinkSync;', - 'fs.symlinkSync = (target, path, ...rest) => {', - ' if (path.startsWith(join(root, ".sample-oven.link."))) { process.stdout.write("staged\\n"); readFileSync(0, "utf8"); }', - ' return symlink(target, path, ...rest);', + 'const rename = fs.renameSync;', + 'fs.renameSync = (from, to, ...rest) => {', + ' if (to === join(root, "sample-oven", "current")) { process.stdout.write("staged\\n"); readFileSync(0, "utf8"); }', + ' return rename(from, to, ...rest);', '};', 'syncBuiltinESMExports();', 'process.argv = [process.argv[0], bin, ...process.argv.slice(3)];', @@ -131,21 +132,22 @@ test("oven create and update swap the package while preserving sibling files", ( instructions: "# Initial Oven\n\nInitial checklist.", detail: detailFixture(), })); run(context, "oven", "create", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); - writeFileSync(join(ovensDir, "sample-oven", "oven.json"), JSON.stringify({ + writeFileSync(join(resolveOvenPackageDir(join(ovensDir, "sample-oven")), "oven.json"), JSON.stringify({ forkedFrom: { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }, })); writeFileSync(packagePath, JSON.stringify({ instructions: "# Updated Oven\n\nUpdated checklist.", detail: detailFixture(), })); run(context, "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); - assert.match(readFileSync(join(ovensDir, "sample-oven", "instructions.md"), "utf8"), /Updated checklist/u); - assert.deepEqual(JSON.parse(readFileSync(join(ovensDir, "sample-oven", "detail.json"), "utf8")), detailFixture()); - assert.equal(JSON.parse(readFileSync(join(ovensDir, "sample-oven", "oven.json"), "utf8")).forkedFrom.ovenId, "source-oven"); - assert.equal(readdirSync(ovensDir).filter((name) => name.startsWith(".")).length, 2); + const current = resolveOvenPackageDir(join(ovensDir, "sample-oven")); + assert.match(readFileSync(join(current, "instructions.md"), "utf8"), /Updated checklist/u); + assert.deepEqual(JSON.parse(readFileSync(join(current, "detail.json"), "utf8")), detailFixture()); + assert.equal(JSON.parse(readFileSync(join(current, "oven.json"), "utf8")).forkedFrom.ovenId, "source-oven"); + assert.equal(readdirSync(join(ovensDir, "sample-oven")).filter((name) => name.startsWith("rev-")).length, 2); } finally { context.cleanup(); } }); -test("oven update migrates a legacy plain directory to a symlink package", () => { +test("oven update migrates a legacy plain directory to a pointer package", () => { const context = fixture(); const ovensDir = join(context.repo, "ovens"); try { @@ -155,7 +157,8 @@ test("oven update migrates a legacy plain directory to a symlink package", () => const packagePath = join(context.repo, "replacement.json"); writeFileSync(packagePath, JSON.stringify({ instructions: "# Updated Oven\n\nMigrated safely.", detail: detailFixture() })); run(context, "oven", "update", "legacy-oven", "--package", packagePath, "--ovens-dir", ovensDir); - assert.equal(lstatSync(join(ovensDir, "legacy-oven")).isSymbolicLink(), true); + assert.match(readFileSync(join(ovensDir, "legacy-oven", "current"), "utf8"), /^rev-[a-f0-9]+\n$/u); + assert.equal(existsSync(join(ovensDir, "legacy-oven", "instructions.md")), false); const oven = JSON.parse(run(context, "oven", "view", "legacy-oven", "--json", "--ovens-dir", ovensDir)); assert.match(oven.instructions, /Migrated safely/u); assert.equal(oven.forkedFrom.ovenId, "source-oven"); @@ -174,7 +177,7 @@ test("oven storage follows the umbrella root when launched from a subdirectory", })); runFrom(subdirectory, "oven", "create", "umbrella-oven", "--package", packagePath); const ovenPath = join(context.repo, ".local", "burnlist", "ovens", "umbrella-oven"); - assert.match(readFileSync(join(ovenPath, "instructions.md"), "utf8"), /Stored with the umbrella/u); + assert.match(readFileSync(join(resolveOvenPackageDir(ovenPath), "instructions.md"), "utf8"), /Stored with the umbrella/u); const ovens = JSON.parse(runFrom(subdirectory, "oven", "list", "--json")); assert.equal(ovens.some((oven) => oven.id === "umbrella-oven"), true); } finally { context.cleanup(); } @@ -209,7 +212,7 @@ test("oven view and list read complete packages on both sides of a publish barri } finally { context.cleanup(); } }); -test("a killed writer leaves the prior symlink package readable and cleans its staged revision", async () => { +test("a killed writer leaves the prior pointer package readable", async () => { const context = fixture(); const ovensDir = join(context.repo, "ovens"); try { @@ -219,17 +222,18 @@ test("a killed writer leaves the prior symlink package readable and cleans its s writeFileSync(packagePath, JSON.stringify({ instructions: "# Interrupted Oven\n\nNever published.", detail: detailFixture() })); const writer = pausedUpdate(context, ovensDir, packagePath); await waitForBarrier(writer); + const priorCurrent = readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"); writer.kill("SIGKILL"); await new Promise((resolve) => writer.on("close", resolve)); const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); assert.match(oldView.instructions, /Initial checklist/u); assert.equal(oldList.name, "Initial Oven"); + assert.equal(readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"), priorCurrent); writeFileSync(packagePath, JSON.stringify({ instructions: "# Recovered Oven\n\nPublished after recovery.", detail: detailFixture() })); run(context, "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); assert.match(JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)).instructions, /Published after recovery/u); - assert.equal(lstatSync(join(ovensDir, "sample-oven")).isSymbolicLink(), true); - assert.equal(readdirSync(ovensDir).filter((name) => name.startsWith(".sample-oven.")).length, 2); + assert.notEqual(readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"), priorCurrent); } finally { context.cleanup(); } }); @@ -242,7 +246,7 @@ test("oven fork writes source revision lineage and discovery exposes it", () => const expectedRevision = ovenRevision({ instructions: source.instructions, detail: source.detail }); const output = run(context, "oven", "fork", "source-oven", "forked-oven", "--ovens-dir", ovensDir); assert.match(output, new RegExp(`Forked from source-oven@${expectedRevision}`)); - assert.deepEqual(JSON.parse(readFileSync(join(ovensDir, "forked-oven", "oven.json"), "utf8")), { + assert.deepEqual(JSON.parse(readFileSync(join(resolveOvenPackageDir(join(ovensDir, "forked-oven")), "oven.json"), "utf8")), { forkedFrom: { ovenId: "source-oven", revision: expectedRevision }, }); const forked = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)) diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 324c50d..f553db6 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -32,7 +32,7 @@ import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; -import { atomicDirectory, atomicOvenPackage, readTextFileWithLimit, safeStat, withOvenPackageLock } from "./fs-safe.mjs"; +import { atomicDirectory, atomicOvenPackage, readTextFileWithLimit, resolveOvenPackageDir, safeStat, withOvenPackageLock } from "./fs-safe.mjs"; import { warmOvenHandler } from "./oven-warm.mjs"; import { LIFECYCLES, @@ -367,7 +367,7 @@ function readOven(root, id, builtIn) { const safeId = ovenId(id); let ovenRoot; try { - ovenRoot = realpathSync(join(root, safeId)); + ovenRoot = resolveOvenPackageDir(realpathSync(join(root, safeId))); } catch (error) { if (error?.code === "ENOENT") return null; throw error; diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 6e51d14..172c819 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -1,6 +1,8 @@ import { randomBytes } from "node:crypto"; -import { cpSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { cpSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +export const OVEN_REV_GRACE_MS = 60_000; export function readTextFileWithLimit(path, maxBytes, label) { const stat = statSync(path); @@ -181,7 +183,7 @@ function missing(error) { function entryAt(path) { try { - return lstatSync(path); + return statSync(path); } catch (error) { if (missing(error)) return null; throw error; @@ -193,116 +195,111 @@ function cleanupError(errors, message) { return new AggregateError(errors, message); } -function publishPackageLink(parent, id, revisionDir, target) { - const temporary = join(parent, `.${id}.link.${randomBytes(8).toString("hex")}`); +function revisionName(value) { + return /^rev-[a-f0-9]+$/u.test(value) ? value : null; +} + +// Readers resolve this once, then use the returned immutable path for every +// file in their package read. ENOENT is deliberately left for callers to treat +// as an ordinary concurrent disappearance. +export function resolveOvenPackageDir(pkgRoot) { + const pointer = join(pkgRoot, "current"); + let current; try { - if (process.platform === "win32") symlinkSync(resolve(revisionDir), temporary, "junction"); - else symlinkSync(basename(revisionDir), temporary, "dir"); - renameSync(temporary, target); + if (!statSync(pointer).isFile()) return pkgRoot; + current = readFileSync(pointer, "utf8").trim(); } catch (error) { - try { - rmSync(temporary, { force: true }); - } catch (cleanupFailure) { - throw cleanupError([error, cleanupFailure], `Could not publish ${id}`); - } + if (missing(error)) return pkgRoot; throw error; } + if (!revisionName(current)) throw new Error(`Invalid Oven current pointer at ${pointer}.`); + return join(pkgRoot, current); } -function migrateLegacyPackage(parent, id, target) { - const revisionDir = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); - const migrating = join(parent, `.${id}.migrating.${randomBytes(8).toString("hex")}`); - cpSync(target, revisionDir, { recursive: true }); - renameSync(target, migrating); - try { - publishPackageLink(parent, id, revisionDir, target); - } catch (error) { - try { - renameSync(migrating, target); - } catch (rollbackFailure) { - throw cleanupError([error, rollbackFailure], `Could not migrate legacy Oven ${id}; original remains at ${migrating}`); - } - throw error; +function currentRevision(pkgRoot, id) { + const pointer = join(pkgRoot, "current"); + const entry = entryAt(pointer); + if (!entry) return null; + if (!entry.isFile()) throw new Error(`Oven ${id} current pointer is not a file.`); + const revision = readFileSync(pointer, "utf8").trim(); + if (!revisionName(revision)) throw new Error(`Oven ${id} current pointer is invalid.`); + const revisionDir = join(pkgRoot, revision); + if (!entryAt(revisionDir)?.isDirectory()) { + throw new Error(`Oven ${id} current pointer names missing revision ${revision}.`); } - rmSync(migrating, { recursive: true, force: true }); return revisionDir; } -function packageRevisionDirs(parent, id, requiredFiles) { - const prefix = `.${id}.`; - try { - return readdirSync(parent, { withFileTypes: true }) - .filter((entry) => ( - entry.isDirectory() - && entry.name.startsWith(prefix) - && !entry.name.startsWith(`${prefix}migrating.`) - && requiredFiles.every((name) => existsSync(join(parent, entry.name, name))) - )) - .map((entry) => join(parent, entry.name)); - } catch (error) { - if (missing(error)) return []; - throw error; +function legacyPackage(pkgRoot) { + return ["instructions.md", "detail.json"].every((name) => entryAt(join(pkgRoot, name))?.isFile()); +} + +function copyPackageFiles(from, to) { + for (const name of ["instructions.md", "detail.json", "oven.json"]) { + if (entryAt(join(from, name))?.isFile()) cpSync(join(from, name), join(to, name)); } } -function recoverPackagePublish(parent, id, target, requiredFiles) { - if (entryAt(target)) return; - const prefix = `.${id}.migrating.`; - let migrating; +function publishCurrent(pkgRoot, id, revision) { + const temporary = join(pkgRoot, `.current.${randomBytes(8).toString("hex")}`); try { - migrating = readdirSync(parent, { withFileTypes: true }) - .find((entry) => entry.isDirectory() && entry.name.startsWith(prefix)); + writeFileSync(temporary, `${revision}\n`); + renameSync(temporary, join(pkgRoot, "current")); } catch (error) { - if (missing(error)) return; + try { + rmSync(temporary, { force: true }); + } catch (cleanupFailure) { + throw cleanupError([error, cleanupFailure], `Could not publish Oven ${id}`); + } throw error; } - if (migrating) { - renameSync(join(parent, migrating.name), target); - return; +} + +function removeLegacyFiles(pkgRoot) { + for (const name of ["instructions.md", "detail.json", "oven.json"]) { + try { + rmSync(join(pkgRoot, name), { force: true }); + } catch { + // The pointer is already durable; a later publish can retry this cleanup. + } } - const revisions = packageRevisionDirs(parent, id, requiredFiles); - if (revisions.length) publishPackageLink(parent, id, revisions[revisions.length - 1], target); } -function cleanupOldPackageDirs(parent, id, current, previous) { - const errors = []; - const prefix = `.${id}.`; +function gcOldRevisions(pkgRoot, current) { try { - for (const entry of readdirSync(parent, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name === basename(current) || !entry.name.startsWith(prefix)) continue; - const candidate = join(parent, entry.name); + for (const entry of readdirSync(pkgRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === current || !revisionName(entry.name)) continue; + const path = join(pkgRoot, entry.name); try { - if (!previous || entry.name !== basename(previous)) rmSync(candidate, { recursive: true, force: true }); + if (Date.now() - statSync(path).mtimeMs >= OVEN_REV_GRACE_MS) rmSync(path, { recursive: true, force: true }); } catch (error) { - if (!missing(error)) errors.push(error); + if (!missing(error)) throw error; } } } catch (error) { - if (!missing(error)) errors.push(error); + if (!missing(error)) throw error; } - if (errors.length) throw cleanupError(errors, `Published ${id}, but could not clean up old Oven revisions`); } -// Custom Oven packages are immutable revision directories published through a -// symlink pointer. The pointer rename leaves readers either on the old complete -// package or the new complete package; readers never need the writer lock. +// Custom Oven packages are immutable nested revisions published by atomically +// replacing a small pointer file. A reader that resolved the old revision keeps +// a stable path until grace-period GC makes it eligible for deletion. export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { mkdirSync(parent, { recursive: true }); - const target = join(parent, id); - recoverPackagePublish(parent, id, target, Object.keys(files)); - const existing = entryAt(target); - if (existing && !replace) throw Object.assign(new Error(`${id} already exists.`), { code: "EEXIST" }); + const pkgRoot = join(parent, id); + mkdirSync(pkgRoot, { recursive: true }); + const previous = currentRevision(pkgRoot, id); + const legacy = !previous && legacyPackage(pkgRoot); + if ((previous || legacy) && !replace) throw Object.assign(new Error(`${id} already exists.`), { code: "EEXIST" }); - let previous = null; - if (existing?.isDirectory() && !existing.isSymbolicLink()) { - previous = migrateLegacyPackage(parent, id, target); - } else if (existing) previous = realpathSync(target); - const revisionDir = join(parent, `.${id}.${randomBytes(8).toString("hex")}`); + const revision = `rev-${randomBytes(8).toString("hex")}`; + const revisionDir = join(pkgRoot, revision); mkdirSync(revisionDir); try { - if (previous) cpSync(previous, revisionDir, { recursive: true }); + if (previous) copyPackageFiles(previous, revisionDir); + else if (legacy) copyPackageFiles(pkgRoot, revisionDir); for (const [name, contents] of Object.entries(files)) writeFileSync(join(revisionDir, name), contents); - publishPackageLink(parent, id, revisionDir, target); + publishCurrent(pkgRoot, id, revision); } catch (error) { try { rmSync(revisionDir, { recursive: true, force: true }); @@ -311,6 +308,7 @@ export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { } throw error; } - cleanupOldPackageDirs(parent, id, revisionDir, previous); - return target; + removeLegacyFiles(pkgRoot); + gcOldRevisions(pkgRoot, revision); + return pkgRoot; } diff --git a/src/server/fs-safe.test.mjs b/src/server/fs-safe.test.mjs index 9b4419f..ed3be51 100644 --- a/src/server/fs-safe.test.mjs +++ b/src/server/fs-safe.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { cpSync, existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { join } from "node:path"; import test from "node:test"; -import { atomicDirectory, atomicOvenPackage, withOvenPackageLock } from "./fs-safe.mjs"; +import { atomicDirectory, atomicOvenPackage, OVEN_REV_GRACE_MS, resolveOvenPackageDir, withOvenPackageLock } from "./fs-safe.mjs"; function fixture() { const root = mkdtempSync(join(tmpdir(), "burnlist-fs-safe-")); @@ -85,48 +85,72 @@ function ovenFiles(version) { }; } -test("Oven package swaps retain a resolved reader revision and only the newest two", () => { +test("Oven pointer swaps retain a resolved reader revision and grace GC keeps young revisions", () => { const context = fixture(); const target = join(context.root, "oven"); try { withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("one"))); - const oldRevision = readFileSync(join(target, "instructions.md"), "utf8"); - const oldPath = realpathSync(target); + const oldPath = resolveOvenPackageDir(target); withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("two"), { replace: true })); - assert.equal(oldRevision, "# Oven one\n"); for (const [name, contents] of Object.entries(ovenFiles("one"))) { assert.equal(readFileSync(join(oldPath, name), "utf8"), contents); } - withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("three"), { replace: true })); - const revisions = readdirSync(context.root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && entry.name.startsWith(".oven.")) + const revisions = readdirSync(target, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith("rev-")) .map((entry) => entry.name); - assert.equal(revisions.length, 2); - assert.equal(revisions.includes(basename(oldPath)), false); + assert.equal(revisions.length, 3); + assert.match(readFileSync(join(target, "current"), "utf8"), /^rev-[a-f0-9]+\n$/u); + + utimesSync(oldPath, new Date(Date.now() - OVEN_REV_GRACE_MS - 1_000), new Date(Date.now() - OVEN_REV_GRACE_MS - 1_000)); + withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("four"), { replace: true })); + assert.equal(existsSync(oldPath), false); + assert.equal(readdirSync(target, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.startsWith("rev-")).length, 3); } finally { context.cleanup(); } }); -test("a crashed legacy Oven migration self-heals before the next publish", () => { +test("legacy migration stays readable on either side of current and recovery trusts current", () => { const context = fixture(); - const target = join(context.root, "oven"); - const migration = join(context.root, ".oven.migrating.crashed"); + const target = join(context.root, "legacy"); + const crashed = join(context.root, "crashed"); + const orphanRoot = join(context.root, "orphan"); try { - atomicDirectory(context.root, "oven", { ...ovenFiles("legacy"), "extra.txt": "preserved\n" }); - cpSync(target, join(context.root, ".oven.revision-before-crash"), { recursive: true }); - renameSync(target, migration); + atomicDirectory(context.root, "legacy", ovenFiles("legacy")); + assert.equal(resolveOvenPackageDir(target), target); + assert.equal(readFileSync(join(resolveOvenPackageDir(target), "instructions.md"), "utf8"), "# Oven legacy\n"); + withOvenPackageLock(context.root, "legacy", () => ( + atomicOvenPackage(context.root, "legacy", ovenFiles("updated"), { replace: true }) + )); + assert.equal(readFileSync(join(resolveOvenPackageDir(target), "instructions.md"), "utf8"), "# Oven updated\n"); + assert.equal(existsSync(join(target, "instructions.md")), false); - withOvenPackageLock(context.root, "oven", () => ( - atomicOvenPackage(context.root, "oven", ovenFiles("updated"), { replace: true }) + atomicDirectory(context.root, "crashed", ovenFiles("old")); + const publishedBeforeCleanup = join(crashed, "rev-cafebabe"); + mkdirSync(publishedBeforeCleanup); + for (const [name, contents] of Object.entries(ovenFiles("new"))) writeFileSync(join(publishedBeforeCleanup, name), contents); + writeFileSync(join(crashed, "current"), "rev-cafebabe\n"); + assert.equal(readFileSync(join(resolveOvenPackageDir(crashed), "instructions.md"), "utf8"), "# Oven new\n"); + assert.equal(readFileSync(join(crashed, "instructions.md"), "utf8"), "# Oven old\n"); + withOvenPackageLock(context.root, "crashed", () => ( + atomicOvenPackage(context.root, "crashed", ovenFiles("recovered"), { replace: true }) )); + assert.equal(existsSync(join(crashed, "instructions.md")), false); - assert.equal(lstatSync(target).isSymbolicLink(), true); - assert.equal(readFileSync(join(target, "instructions.md"), "utf8"), "# Oven updated\n"); - assert.equal(readFileSync(join(target, "extra.txt"), "utf8"), "preserved\n"); - assert.equal(existsSync(migration), false); + mkdirSync(orphanRoot, { recursive: true }); + const orphanRevision = join(orphanRoot, "rev-deadbeef"); + mkdirSync(orphanRevision); + for (const [name, contents] of Object.entries(ovenFiles("orphan"))) writeFileSync(join(orphanRevision, name), contents); + withOvenPackageLock(context.root, "orphan", () => atomicOvenPackage(context.root, "orphan", ovenFiles("created"))); + assert.equal(readFileSync(join(resolveOvenPackageDir(orphanRoot), "instructions.md"), "utf8"), "# Oven created\n"); + + writeFileSync(join(target, "current"), "rev-facefeed\n"); + assert.throws( + () => withOvenPackageLock(context.root, "legacy", () => atomicOvenPackage(context.root, "legacy", ovenFiles("bad"), { replace: true })), + /missing revision rev-facefeed/u, + ); } finally { context.cleanup(); } From 0263c9525cff5c95ec08d398789e680482fe0aa7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 13:32:46 +0200 Subject: [PATCH 19/30] fix: retirement-grace oven rev GC, fail-closed reads, strict pointer parse --- src/server/fs-safe.mjs | 46 +++++++++++++++++-------- src/server/fs-safe.test.mjs | 69 ++++++++++++++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 172c819..312dad0 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { cpSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; export const OVEN_REV_GRACE_MS = 60_000; @@ -183,7 +183,7 @@ function missing(error) { function entryAt(path) { try { - return statSync(path); + return lstatSync(path); } catch (error) { if (missing(error)) return null; throw error; @@ -199,21 +199,26 @@ function revisionName(value) { return /^rev-[a-f0-9]+$/u.test(value) ? value : null; } +function pointerRevision(value) { + const match = /^(rev-[a-f0-9]+)\n?$/u.exec(value); + return match ? revisionName(match[1]) : null; +} + // Readers resolve this once, then use the returned immutable path for every // file in their package read. ENOENT is deliberately left for callers to treat // as an ordinary concurrent disappearance. export function resolveOvenPackageDir(pkgRoot) { const pointer = join(pkgRoot, "current"); - let current; - try { - if (!statSync(pointer).isFile()) return pkgRoot; - current = readFileSync(pointer, "utf8").trim(); - } catch (error) { - if (missing(error)) return pkgRoot; - throw error; - } + const entry = entryAt(pointer); + if (!entry) return pkgRoot; + if (!entry.isFile()) throw new Error(`Invalid Oven current pointer at ${pointer}: not a file.`); + const current = pointerRevision(readFileSync(pointer, "utf8")); if (!revisionName(current)) throw new Error(`Invalid Oven current pointer at ${pointer}.`); - return join(pkgRoot, current); + const revisionDir = join(pkgRoot, current); + if (!entryAt(revisionDir)?.isDirectory()) { + throw new Error(`Invalid Oven current pointer at ${pointer}: missing revision ${current}.`); + } + return revisionDir; } function currentRevision(pkgRoot, id) { @@ -221,7 +226,7 @@ function currentRevision(pkgRoot, id) { const entry = entryAt(pointer); if (!entry) return null; if (!entry.isFile()) throw new Error(`Oven ${id} current pointer is not a file.`); - const revision = readFileSync(pointer, "utf8").trim(); + const revision = pointerRevision(readFileSync(pointer, "utf8")); if (!revisionName(revision)) throw new Error(`Oven ${id} current pointer is invalid.`); const revisionDir = join(pkgRoot, revision); if (!entryAt(revisionDir)?.isDirectory()) { @@ -268,8 +273,16 @@ function removeLegacyFiles(pkgRoot) { function gcOldRevisions(pkgRoot, current) { try { for (const entry of readdirSync(pkgRoot, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name === current || !revisionName(entry.name)) continue; const path = join(pkgRoot, entry.name); + if (entry.isFile() && entry.name.startsWith(".current.")) { + try { + rmSync(path, { force: true }); + } catch (error) { + if (!missing(error)) throw error; + } + continue; + } + if (!entry.isDirectory() || entry.name === current || !revisionName(entry.name)) continue; try { if (Date.now() - statSync(path).mtimeMs >= OVEN_REV_GRACE_MS) rmSync(path, { recursive: true, force: true }); } catch (error) { @@ -283,7 +296,8 @@ function gcOldRevisions(pkgRoot, current) { // Custom Oven packages are immutable nested revisions published by atomically // replacing a small pointer file. A reader that resolved the old revision keeps -// a stable path until grace-period GC makes it eligible for deletion. +// a stable path until grace-period GC makes it eligible for deletion. A reader +// suspended beyond that grace period mid-read is out of scope for this localhost tool. export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { mkdirSync(parent, { recursive: true }); const pkgRoot = join(parent, id); @@ -308,6 +322,10 @@ export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { } throw error; } + if (previous) { + const retiredAt = new Date(); + utimesSync(previous, retiredAt, retiredAt); + } removeLegacyFiles(pkgRoot); gcOldRevisions(pkgRoot, revision); return pkgRoot; diff --git a/src/server/fs-safe.test.mjs b/src/server/fs-safe.test.mjs index ed3be51..4d5015a 100644 --- a/src/server/fs-safe.test.mjs +++ b/src/server/fs-safe.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -85,28 +85,84 @@ function ovenFiles(version) { }; } -test("Oven pointer swaps retain a resolved reader revision and grace GC keeps young revisions", () => { +test("Oven pointer swaps measure revision GC grace from retirement", () => { const context = fixture(); const target = join(context.root, "oven"); try { withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("one"))); const oldPath = resolveOvenPackageDir(target); + const longAgo = new Date(Date.now() - OVEN_REV_GRACE_MS - 1_000); + utimesSync(oldPath, longAgo, longAgo); withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("two"), { replace: true })); for (const [name, contents] of Object.entries(ovenFiles("one"))) { assert.equal(readFileSync(join(oldPath, name), "utf8"), contents); } + assert.ok(Date.now() - statSync(oldPath).mtimeMs < OVEN_REV_GRACE_MS); + + const stale = join(target, "rev-deadbeef"); + mkdirSync(stale); + utimesSync(stale, longAgo, longAgo); withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("three"), { replace: true })); + assert.equal(existsSync(stale), false); + assert.equal(existsSync(oldPath), true); const revisions = readdirSync(target, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith("rev-")) .map((entry) => entry.name); assert.equal(revisions.length, 3); assert.match(readFileSync(join(target, "current"), "utf8"), /^rev-[a-f0-9]+\n$/u); - utimesSync(oldPath, new Date(Date.now() - OVEN_REV_GRACE_MS - 1_000), new Date(Date.now() - OVEN_REV_GRACE_MS - 1_000)); + const currentPath = resolveOvenPackageDir(target); + utimesSync(currentPath, longAgo, longAgo); withOvenPackageLock(context.root, "oven", () => atomicOvenPackage(context.root, "oven", ovenFiles("four"), { replace: true })); - assert.equal(existsSync(oldPath), false); - assert.equal(readdirSync(target, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.startsWith("rev-")).length, 3); + assert.equal(existsSync(currentPath), true); + assert.equal(existsSync(resolveOvenPackageDir(target)), true); + } finally { + context.cleanup(); + } +}); + +test("Oven readers only fall back for an absent current pointer", () => { + const context = fixture(); + try { + const flat = join(context.root, "flat"); + mkdirSync(flat); + for (const [name, contents] of Object.entries(ovenFiles("flat"))) writeFileSync(join(flat, name), contents); + assert.equal(resolveOvenPackageDir(flat), flat); + + const missing = join(context.root, "missing"); + mkdirSync(missing); + writeFileSync(join(missing, "current"), "rev-deadbeef\n"); + assert.throws(() => resolveOvenPackageDir(missing), /missing revision rev-deadbeef/u); + + const nonFile = join(context.root, "non-file"); + mkdirSync(join(nonFile, "current"), { recursive: true }); + assert.throws(() => resolveOvenPackageDir(nonFile), /not a file/u); + + const linked = join(context.root, "linked"); + mkdirSync(linked); + writeFileSync(join(linked, "pointer-target"), "rev-deadbeef\n"); + symlinkSync("pointer-target", join(linked, "current")); + assert.throws(() => resolveOvenPackageDir(linked), /not a file/u); + } finally { + context.cleanup(); + } +}); + +test("Oven current pointers require one exact revision line", () => { + const context = fixture(); + const target = join(context.root, "oven"); + try { + mkdirSync(target); + mkdirSync(join(target, "rev-deadbeef")); + for (const contents of [" rev-deadbeef", "rev-deadbeef\nextra", "rev-deadbeef \n"]) { + writeFileSync(join(target, "current"), contents); + assert.throws(() => resolveOvenPackageDir(target), /Invalid Oven current pointer/u); + } + for (const contents of ["rev-deadbeef", "rev-deadbeef\n"]) { + writeFileSync(join(target, "current"), contents); + assert.equal(resolveOvenPackageDir(target), join(target, "rev-deadbeef")); + } } finally { context.cleanup(); } @@ -143,8 +199,11 @@ test("legacy migration stays readable on either side of current and recovery tru const orphanRevision = join(orphanRoot, "rev-deadbeef"); mkdirSync(orphanRevision); for (const [name, contents] of Object.entries(ovenFiles("orphan"))) writeFileSync(join(orphanRevision, name), contents); + const orphanPointerTemp = join(orphanRoot, ".current.deadcafe"); + writeFileSync(orphanPointerTemp, "rev-deadbeef\n"); withOvenPackageLock(context.root, "orphan", () => atomicOvenPackage(context.root, "orphan", ovenFiles("created"))); assert.equal(readFileSync(join(resolveOvenPackageDir(orphanRoot), "instructions.md"), "utf8"), "# Oven created\n"); + assert.equal(existsSync(orphanPointerTemp), false); writeFileSync(join(target, "current"), "rev-facefeed\n"); assert.throws( From 750ef5f138ba27f7721b484039b1dab6eeaf9be3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 13:37:18 +0200 Subject: [PATCH 20/30] fix: touch retired oven rev before pointer swap for crash-safe grace --- src/server/fs-safe.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 312dad0..78f5086 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -313,6 +313,12 @@ export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { if (previous) copyPackageFiles(previous, revisionDir); else if (legacy) copyPackageFiles(pkgRoot, revisionDir); for (const [name, contents] of Object.entries(files)) writeFileSync(join(revisionDir, name), contents); + // Touch the outgoing rev to now BEFORE the swap so its grace window starts at retirement even if + // the process dies right after publishCurrent (a post-swap touch could leave it stale for GC). + if (previous) { + const retiredAt = new Date(); + utimesSync(previous, retiredAt, retiredAt); + } publishCurrent(pkgRoot, id, revision); } catch (error) { try { @@ -322,10 +328,6 @@ export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { } throw error; } - if (previous) { - const retiredAt = new Date(); - utimesSync(previous, retiredAt, retiredAt); - } removeLegacyFiles(pkgRoot); gcOldRevisions(pkgRoot, revision); return pkgRoot; From 6eaab94b9a5e617f418544c76c00328d5d26cd10 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 15:11:23 +0200 Subject: [PATCH 21/30] fix: umbrella oven root, path containment, per-oven isolation, registry integrity --- dashboard/src/lib/types.ts | 1 + .../engine/differential-testing-handler.mjs | 36 ++++-- src/cli/oven-cli.mjs | 29 ++++- src/cli/oven-cli.test.mjs | 39 ++++++- src/ovens/oven-registry.mjs | 15 ++- src/ovens/oven-registry.test.mjs | 19 +++- src/server/burnlist-dashboard-server.mjs | 72 ++++++++++-- src/server/dashboard-routes.test.mjs | 105 ++++++++++++++++-- src/server/fs-safe.mjs | 17 ++- src/server/fs-safe.test.mjs | 53 ++++++++- 10 files changed, 329 insertions(+), 57 deletions(-) diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index f95a373..218f4e1 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -43,6 +43,7 @@ export type Burnlist = { ovenName: string; href: string; progressLabel: string; + blockers?: string; }; export type Project = { diff --git a/ovens/differential-testing/engine/differential-testing-handler.mjs b/ovens/differential-testing/engine/differential-testing-handler.mjs index f36cab6..7db78d8 100644 --- a/ovens/differential-testing/engine/differential-testing-handler.mjs +++ b/ovens/differential-testing/engine/differential-testing-handler.mjs @@ -181,18 +181,30 @@ export const differentialTestingHandler = Object.freeze({ dashboardEntries(ctx) { const bindings = ctx.ovenDataBindings.get("differential-testing") ?? []; return bindings.flatMap((binding) => { - const index = differentialTestingIndexCache(ctx, binding.path); - const repo = binding.repoKey === null - ? "differential-testing" - : ctx.discoveredRepos().find((entry) => entry.repoKey === binding.repoKey)?.name ?? basename(binding.repoRoot); - return index.scenarios.map((scenario) => ({ - id: scenario.id, repo, repoKey: binding.repoKey, repoRoot: binding.repoRoot, title: scenario.label, - 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)}`}`, - progressLabel: `${scenario.frameCount} frames`, - })); + try { + const index = differentialTestingIndexCache(ctx, binding.path); + const repo = binding.repoKey === null + ? "differential-testing" + : ctx.discoveredRepos().find((entry) => entry.repoKey === binding.repoKey)?.name ?? basename(binding.repoRoot); + return index.scenarios.map((scenario) => ({ + id: scenario.id, repo, repoKey: binding.repoKey, repoRoot: binding.repoRoot, title: scenario.label, + 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)}`}`, + progressLabel: `${scenario.frameCount} frames`, + })); + } catch (error) { + const repo = binding.repoKey === null ? "differential-testing" : basename(binding.repoRoot); + return [{ + id: `blocked-${binding.repoKey ?? "global"}`, repo, repoKey: binding.repoKey, repoRoot: binding.repoRoot, + title: "Differential Testing", planPath: "", 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", + progressLabel: "Blocked", blockers: String(error?.message ?? error ?? "Data binding is unavailable.").slice(0, 200), + }]; + } }); }, diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index f409f88..67bbd14 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -8,11 +8,12 @@ // 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 { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; import { atomicOvenPackage, resolveOvenPackageDir, withOvenPackageLock } from "../server/fs-safe.mjs"; +import { containedJoin } from "../server/repo-state.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; @@ -60,7 +61,27 @@ function bindingRepo() { const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const builtInOvensDir = resolve(packageRoot, "ovens"); const launchCwd = process.cwd(); -const customOvensDir = resolve(repoRoot(), flags.get("ovens-dir") ?? ".local/burnlist/ovens"); +const customOvensDir = flags.has("ovens-dir") + ? resolve(repoRoot(), flags.get("ovens-dir")) + : containedJoin(repoRoot(), "ovens"); + +function isWithin(parent, child) { + const pathFromParent = relative(parent, child); + return pathFromParent === "" + || (pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent)); +} + +function assertCustomOvenPath(root, id) { + const path = join(root, id); + try { + const ovensRoot = realpathSync(root); + const ovenRoot = realpathSync(path); + if (!isWithin(ovensRoot, ovenRoot)) throw new Error(`Custom Oven ${id} escapes ${root}.`); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + return path; +} function safeStat(path) { try { @@ -94,7 +115,8 @@ function readOvenDir(root, id, builtIn) { const safeId = ovenId(id); let ovenRoot; try { - ovenRoot = resolveOvenPackageDir(realpathSync(join(root, safeId))); + const path = builtIn ? join(root, safeId) : assertCustomOvenPath(root, safeId); + ovenRoot = resolveOvenPackageDir(realpathSync(path)); } catch (error) { if (error?.code === "ENOENT") return null; throw error; @@ -234,6 +256,7 @@ function persistOven(pkg, { allowReplace, sidecar }) { ...(sidecar ? { "oven.json": `${JSON.stringify(sidecar, null, 2)}\n` } : {}), }; try { + assertCustomOvenPath(customOvensDir, pkg.id); return withOvenPackageLock(customOvensDir, pkg.id, () => ( atomicOvenPackage(customOvensDir, pkg.id, files, { replace: allowReplace }) )); diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index 0a7b96b..9771f0d 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { ovenRevision } from "../ovens/oven-contract.mjs"; import { resolveOvenPackageDir } from "../server/fs-safe.mjs"; @@ -183,6 +183,41 @@ test("oven storage follows the umbrella root when launched from a subdirectory", } finally { context.cleanup(); } }); +test("oven storage rejects symlink escapes for its state directory and individual Oven ids", () => { + const context = fixture(); + const packagePath = join(context.repo, "package.json"); + const outside = join(dirname(context.repo), "outside"); + try { + writeFileSync(packagePath, JSON.stringify({ instructions: "# Contained Oven\n\nStay in the repo.", detail: detailFixture() })); + mkdirSync(outside); + symlinkSync(outside, join(context.repo, ".local"), "dir"); + assert.throws( + () => run(context, "oven", "create", "unsafe-oven", "--package", packagePath), + (error) => String(error.stderr).includes("escapes"), + ); + assert.equal(existsSync(join(outside, "burnlist", "ovens", "unsafe-oven")), false); + } finally { context.cleanup(); } + + const second = fixture(); + try { + const ovensDir = join(second.repo, ".local", "burnlist", "ovens"); + const idOutside = join(second.repo, "id-outside"); + const packagePath = join(second.repo, "package.json"); + mkdirSync(ovensDir, { recursive: true }); + mkdirSync(idOutside); + symlinkSync(idOutside, join(ovensDir, "escaped-oven"), "dir"); + writeFileSync(packagePath, JSON.stringify({ instructions: "# Escaped Oven\n\nNope.", detail: detailFixture() })); + assert.throws( + () => run(second, "oven", "view", "escaped-oven", "--json"), + (error) => String(error.stderr).includes("escapes"), + ); + assert.throws( + () => run(second, "oven", "create", "escaped-oven", "--package", packagePath), + (error) => String(error.stderr).includes("escapes"), + ); + } finally { second.cleanup(); } +}); + test("oven view and list read complete packages on both sides of a publish barrier", async () => { const context = fixture(); const ovensDir = join(context.repo, "ovens"); diff --git a/src/ovens/oven-registry.mjs b/src/ovens/oven-registry.mjs index a077277..3b568fe 100644 --- a/src/ovens/oven-registry.mjs +++ b/src/ovens/oven-registry.mjs @@ -10,8 +10,19 @@ 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.`); if (handlers.has(normalizedId)) throw new Error(`Oven handler for ${normalizedId} is already registered.`); - handlers.set(normalizedId, handler); - return handler; + if (handler.id !== id) throw new Error(`Oven handler id must equal its registry key ${normalizedId}.`); + for (const hook of ["dashboardEntries", "serveData", "warm"]) { + if (handler[hook] !== undefined && typeof handler[hook] !== "function") { + throw new Error(`Oven handler ${normalizedId} ${hook} must be a function.`); + } + } + if (handler.warmIntervalMs !== undefined + && (!Number.isInteger(handler.warmIntervalMs) || handler.warmIntervalMs <= 0)) { + throw new Error(`Oven handler ${normalizedId} warmIntervalMs must be a positive integer.`); + } + const registered = Object.freeze({ ...handler, id: normalizedId }); + handlers.set(normalizedId, registered); + return registered; } export function getOvenHandler(id) { diff --git a/src/ovens/oven-registry.test.mjs b/src/ovens/oven-registry.test.mjs index 676b6f4..fa35ac6 100644 --- a/src/ovens/oven-registry.test.mjs +++ b/src/ovens/oven-registry.test.mjs @@ -3,13 +3,20 @@ import test from "node:test"; import { getOvenHandler, listOvenHandlers, registerOvenHandler } from "./oven-registry.mjs"; test("the Oven handler registry validates and retrieves code-owned handlers", () => { - const handler = {}; - registerOvenHandler("registry-test", handler); + const handler = { id: "registry-test", dashboardEntries() { return []; } }; + const registered = registerOvenHandler("registry-test", handler); - assert.equal(getOvenHandler("registry-test"), handler); - assert.equal(listOvenHandlers().includes(handler), true); + assert.equal(getOvenHandler("registry-test"), registered); + assert.equal(listOvenHandlers().includes(registered), true); + assert.equal(Object.isFrozen(registered), true); + handler.id = "mutated-id"; + assert.equal(getOvenHandler("registry-test").id, "registry-test"); + assert.equal(listOvenHandlers()[0].id, "registry-test"); assert.equal(getOvenHandler("not-registered"), null); assert.equal(getOvenHandler("Invalid id"), null); - assert.throws(() => registerOvenHandler("registry-test", {}), /already registered/u); - assert.throws(() => registerOvenHandler("Invalid id", {}), /lowercase slug/u); + assert.throws(() => registerOvenHandler("registry-test", { id: "registry-test" }), /already registered/u); + assert.throws(() => registerOvenHandler("Invalid id", { id: "Invalid id" }), /lowercase slug/u); + assert.throws(() => registerOvenHandler("registry-mismatch", { id: "another-id" }), /must equal/u); + 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); }); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index f553db6..d34101b 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -11,7 +11,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { basename, dirname, join, relative, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import os from "node:os"; import { mutatorRepoRoots, observerRepoRoots } from "./discovery.mjs"; @@ -19,6 +19,7 @@ import { effectiveBindings } from "./oven-bindings.mjs"; import { classifyRoots, readRegistry, repoKey } from "./registry.mjs"; import { buildProjectsSnapshot } from "./projects.mjs"; import { containedJoin, repoStateDir, withRepoStateLock } from "./repo-state.mjs"; +import { resolveUmbrella } from "../cli/umbrella.mjs"; import { assertKnownKeys, boundedText, @@ -106,7 +107,10 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") const dashboardDistDir = resolve(packageRoot, "dashboard", "dist"); const dashboardIndexPath = resolve(dashboardDistDir, "index.html"); const builtInOvensDir = resolve(packageRoot, "ovens"); -const customOvensDir = resolve(launchCwd, args.get("ovens-dir") ?? ".local/burnlist/ovens"); +const umbrellaRoot = resolveUmbrella(launchCwd); +const customOvensDir = args.has("ovens-dir") + ? resolve(umbrellaRoot, args.get("ovens-dir")) + : containedJoin(umbrellaRoot, "ovens"); const legacyRunsDir = args.has("runs-dir") ? resolve(launchCwd, args.get("runs-dir")) : null; const ovenDataOverrides = parseOvenDataBindings(args.get("oven-data") ?? ""); const writeToken = randomBytes(24).toString("hex"); @@ -266,10 +270,15 @@ function discoverBurnlists() { } function dashboardEntries(ovenDataBindings = resolvedOvenDataBindings()) { - return listOvenHandlers().flatMap((handler) => { - const id = handler.id; - return handler.dashboardEntries?.(ovenHandlerContext({ id, oven: { id }, ovenDataBindings })) ?? []; - }) + const entries = []; + for (const handler of listOvenHandlers()) { + try { + entries.push(...(handler.dashboardEntries?.(ovenHandlerContext({ id: handler.id, oven: { id: handler.id }, ovenDataBindings })) ?? [])); + } catch (error) { + entries.push(blockedDashboardEntry(handler, error)); + } + } + return entries .map((entry) => { let key = null; try { @@ -282,6 +291,17 @@ function dashboardEntries(ovenDataBindings = resolvedOvenDataBindings()) { .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))); } +function blockedDashboardEntry(handler, error) { + const blockers = String(error?.message ?? error ?? "Oven dashboard data is unavailable.").slice(0, 200); + return { + id: `blocked-${handler.id}`, repo: "Oven", repoKey: null, repoRoot: null, planPath: "", + title: handler.id, planLabel: "Oven dashboard", status: "active", statusLabel: "Blocked", + total: 0, done: null, remaining: null, percent: null, errors: 1, warnings: 0, + lastCompletedAt: null, updatedAt: null, ovenId: handler.id, ovenName: handler.id, + href: "/", progressLabel: "Blocked", blockers, + }; +} + function ovenHandlerContext({ id, oven, req, res, url, bindingPath, ovenDataBindings = resolvedOvenDataBindings() } = {}) { const cacheId = id ?? oven?.id; if (cacheId && !ovenHandlerCaches.has(cacheId)) ovenHandlerCaches.set(cacheId, new Map()); @@ -367,7 +387,8 @@ function readOven(root, id, builtIn) { const safeId = ovenId(id); let ovenRoot; try { - ovenRoot = resolveOvenPackageDir(realpathSync(join(root, safeId))); + const path = builtIn ? join(root, safeId) : assertCustomOvenPath(root, safeId); + ovenRoot = resolveOvenPackageDir(realpathSync(path)); } catch (error) { if (error?.code === "ENOENT") return null; throw error; @@ -419,10 +440,35 @@ function ovensIn(root, builtIn) { return entries .map((entry) => entry.name) .filter((id) => !id.startsWith(".") && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) - .map((id) => readOven(root, id, builtIn)) + .map((id) => { + try { + return readOven(root, id, builtIn); + } catch (error) { + console.warn(`Ignoring malformed Oven ${id}: ${error.message}`); + return null; + } + }) .filter(Boolean); } +function isWithin(parent, child) { + const pathFromParent = relative(parent, child); + return pathFromParent === "" + || (pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent)); +} + +function assertCustomOvenPath(root, id) { + const path = join(root, id); + try { + const ovensRoot = realpathSync(root); + const ovenRoot = realpathSync(path); + if (!isWithin(ovensRoot, ovenRoot)) throw new Error(`Custom Oven ${id} escapes ${root}.`); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + return path; +} + function discoverOvens() { const byId = new Map(); for (const oven of ovensIn(builtInOvensDir, true)) byId.set(oven.id, oven); @@ -432,6 +478,11 @@ function discoverOvens() { return [...byId.values()].sort((left, right) => Number(right.builtIn) - Number(left.builtIn) || left.name.localeCompare(right.name)); } +function findOven(id) { + const safeId = ovenId(id); + return readOven(builtInOvensDir, safeId, true) ?? readOven(customOvensDir, safeId, false); +} + function ovenSummary(oven) { return { id: oven.id, @@ -451,7 +502,7 @@ function ovenSummary(oven) { function createOven(value) { assertKnownKeys(value, new Set(["id", "name", "instructions", "detail"]), "Oven"); const id = ovenId(value.id); - if (discoverOvens().some((oven) => oven.id === id)) throw new Error(`Oven ${id} already exists.`); + if (findOven(id)) throw new Error(`Oven ${id} already exists.`); const name = boundedText(value.name, "Oven name", 80); let instructions = boundedText(value.instructions, "Markdown instructions", 65536); const instructionLines = instructions.split(/\r?\n/u); @@ -461,6 +512,7 @@ function createOven(value) { instructions = instructionLines.join("\n"); const detail = normalizeOvenDetail(value.detail); const ovenPackage = normalizeOvenPackage({ id, instructions, detail }); + assertCustomOvenPath(customOvensDir, id); const path = withOvenPackageLock(customOvensDir, id, () => atomicOvenPackage(customOvensDir, id, { "instructions.md": `${ovenPackage.instructions}\n`, "detail.json": `${JSON.stringify(ovenPackage.detail, null, 2)}\n`, @@ -983,7 +1035,7 @@ const server = createServer(async (req, res) => { const ovenRoute = url.pathname.match(/^\/api\/ovens\/([a-z0-9]+(?:-[a-z0-9]+)*)$/u); if (ovenRoute) { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); - const oven = discoverOvens().find((entry) => entry.id === ovenRoute[1]); + const oven = findOven(ovenRoute[1]); if (!oven) return json(res, 404, { error: "oven not found" }); json(res, 200, { oven }); return; diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index bd39ed2..62515ef 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createServer, get, request } from "node:http"; import { existsSync, realpathSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -80,7 +80,7 @@ test("/api/burnlists lists discovered Burnlists across the observer set", { time }); }); -test("registered Oven routes and dashboard entries ignore malformed custom Oven packages", { timeout: 20_000 }, async () => { +test("registered Oven routes and dashboard entries isolate malformed custom Oven packages", { timeout: 20_000 }, async () => { const timestamp = "2026-01-01T12:00:00.000Z"; const differentialTestingPayload = buildPayload( { @@ -115,8 +115,11 @@ test("registered Oven routes and dashboard entries ignore malformed custom Oven assert.equal(entries.some((entry) => entry.ovenId === "differential-testing"), true); const ovens = await httpGet(baseUrl, "/api/ovens"); - assert.equal(ovens.status, 400); - assert.match(JSON.parse(ovens.body).error, /lineage sidecar is invalid/u); + assert.equal(ovens.status, 200); + assert.equal(JSON.parse(ovens.body).ovens.some((oven) => oven.id === "checklist"), true); + const malformed = await httpGet(baseUrl, "/api/ovens/malformed-oven"); + assert.equal(malformed.status, 400); + assert.match(JSON.parse(malformed.body).error, /lineage sidecar is invalid/u); }); }); @@ -179,6 +182,36 @@ test("Differential Testing bindings remain distinct for each repository", { time }); }); +test("invalid Differential Testing bindings render blocked rows without hiding valid bindings or Checklists", { timeout: 20_000 }, async () => { + const timestamp = "2026-01-01T12:00:00.000Z"; + const valid = buildPayload( + { + captureId: "valid-repo", generatedAt: timestamp, + fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], + samples: [{ tick: 0, values: { position: 1 } }], + }, + { captureId: "valid-repo-candidate", generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, + ); + await withServer({ + burnlists: [{ repoPath: "a/broken" }, { repoPath: "b/valid" }], + scanRoots: ["a", "b"], + ovenData: [ + { id: "differential-testing", payload: {}, repoPath: "a/broken", persisted: true, override: false }, + { id: "differential-testing", payload: valid, repoPath: "b/valid", persisted: true, override: false }, + ], + }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, "/api/burnlists"); + assert.equal(response.status, 200); + const entries = JSON.parse(response.body).burnlists; + assert.equal(entries.filter((entry) => entry.ovenId === "checklist").length, 2); + const blocked = entries.find((entry) => entry.ovenId === "differential-testing" && entry.statusLabel === "Blocked"); + assert.ok(blocked); + assert.equal(blocked.status, "active"); + assert.equal(typeof blocked.blockers, "string"); + assert.equal(entries.some((entry) => entry.ovenId === "differential-testing" && entry.statusLabel === "Active"), true); + }); +}); + test("Oven data repo binding falls back to the global override", { timeout: 20_000 }, async () => { const timestamp = "2026-01-01T12:00:00.000Z"; const payloadFor = (captureId) => buildPayload( @@ -221,7 +254,7 @@ test("Oven data repo binding falls back to the global override", { timeout: 20_0 }); }); -test("Oven discovery exposes optional lineage and rejects malformed sidecars", { timeout: 20_000 }, async () => { +test("Oven discovery exposes optional lineage, skips malformed catalog entries, and keeps direct reads closed", { timeout: 20_000 }, async () => { const forkedFrom = { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }; await withServer({ ovens: [ @@ -236,8 +269,51 @@ test("Oven discovery exposes optional lineage and rejects malformed sidecars", { }); await withServer({ ovens: [{ id: "broken-oven", ovenJson: "{" }] }, async ({ baseUrl }) => { const response = await httpGet(baseUrl, "/api/ovens"); - assert.equal(response.status, 400); - assert.match(JSON.parse(response.body).error, /lineage sidecar is invalid/u); + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).ovens.some((oven) => oven.id === "broken-oven"), false); + const direct = await httpGet(baseUrl, "/api/ovens/broken-oven"); + assert.equal(direct.status, 400); + assert.match(JSON.parse(direct.body).error, /lineage sidecar is invalid/u); + }); +}); + +test("dashboard custom Oven storage follows its umbrella root and rejects symlink escapes", { timeout: 20_000 }, async () => { + await withServer({ + withBurnlist: true, + launchCwd: "fixture-repo/work/nested", + ovensRoot: "fixture-repo", + ovens: [{ id: "umbrella-oven" }], + }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, "/api/ovens"); + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).ovens.some((oven) => oven.id === "umbrella-oven"), true); + }); + + await assert.rejects(() => withServer({ + withBurnlist: true, + launchCwd: "fixture-repo", + setup: async ({ fixtureRoot }) => { + const repo = join(fixtureRoot, "fixture-repo"); + const outside = join(fixtureRoot, "outside"); + await mkdir(outside); + await symlink(outside, join(repo, ".local"), "dir"); + }, + }, async () => assert.fail("server must refuse an escaped custom Oven directory")), /escapes/u); + + await withServer({ + withBurnlist: true, + launchCwd: "fixture-repo", + setup: async ({ fixtureRoot }) => { + const ovens = join(fixtureRoot, "fixture-repo", ".local", "burnlist", "ovens"); + const outside = join(fixtureRoot, "id-outside"); + await mkdir(ovens, { recursive: true }); + await mkdir(outside); + await symlink(outside, join(ovens, "escaped-oven"), "dir"); + }, + }, async ({ baseUrl }) => { + const direct = await httpGet(baseUrl, "/api/ovens/escaped-oven"); + assert.equal(direct.status, 400); + assert.match(JSON.parse(direct.body).error, /escapes/u); }); }); @@ -324,7 +400,10 @@ test("Burn runs read max-size normalized v4 Oven snapshots", { timeout: 20_000 } }); }); -async function withServer({ withBurnlist, burnlists, ovenData = [], ovens = [], runs = [], scanRoots }, callback) { +async function withServer({ + withBurnlist, burnlists, ovenData = [], ovens = [], runs = [], scanRoots, + launchCwd = ".", ovensRoot = ".", setup, +}, callback) { const fixtureRoot = await mkdtemp(join(tmpdir(), "burnlist-dashboard-routes-")); const homeRoot = join(fixtureRoot, "home"); const fixtures = burnlists ?? (withBurnlist ? [{}] : []); @@ -361,8 +440,10 @@ async function withServer({ withBurnlist, burnlists, ovenData = [], ovens = [], await mkdir(dirname(storePath), { recursive: true }); await writeFile(storePath, JSON.stringify({ schemaVersion: 1, bindings })); })); - await Promise.all(ovens.map((oven) => writeOvenFixture(fixtureRoot, oven))); + if (setup) await setup({ fixtureRoot, homeRoot }); + await Promise.all(ovens.map((oven) => writeOvenFixture(join(fixtureRoot, ovensRoot), oven))); await Promise.all(runs.map((run) => writeRunFixture(fixtureRoot, run))); + await mkdir(join(fixtureRoot, launchCwd), { recursive: true }); const port = await availablePort(); const ovenDataBindings = writtenOvenData .filter((entry) => entry.override !== false) @@ -375,7 +456,7 @@ async function withServer({ withBurnlist, burnlists, ovenData = [], ovens = [], "--state-dir", join(fixtureRoot, "state"), ...(ovenDataBindings ? ["--oven-data", ovenDataBindings] : []), ], { - cwd: fixtureRoot, + cwd: join(fixtureRoot, launchCwd), env: { ...process.env, HOME: homeRoot }, stdio: ["ignore", "pipe", "pipe"], }); @@ -441,8 +522,8 @@ function detailFixture() { }; } -async function writeOvenFixture(fixtureRoot, fixture) { - const ovenRoot = join(fixtureRoot, ".local", "burnlist", "ovens", fixture.id); +async function writeOvenFixture(root, fixture) { + const ovenRoot = join(root, ".local", "burnlist", "ovens", fixture.id); await mkdir(ovenRoot, { recursive: true }); await Promise.all([ writeFile(join(ovenRoot, "instructions.md"), fixture.instructions ?? "# Fixture Oven\n\nFollow the checklist.\n"), diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 78f5086..0729d39 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -161,7 +161,7 @@ export function atomicDirectory(parent, id, files, { replace = false, preserveEx try { rmSync(previous, { recursive: true, force: true }); } catch (cleanupError) { - throw new Error(`Updated ${id}, but could not clean up ${previous}: ${cleanupError.message}`, { cause: cleanupError }); + console.warn(`Updated ${id}, but could not clean up ${previous}: ${cleanupError.message}`); } } catch (error) { try { @@ -264,8 +264,9 @@ function removeLegacyFiles(pkgRoot) { for (const name of ["instructions.md", "detail.json", "oven.json"]) { try { rmSync(join(pkgRoot, name), { force: true }); - } catch { + } catch (error) { // The pointer is already durable; a later publish can retry this cleanup. + console.warn(`Could not remove legacy Oven file ${join(pkgRoot, name)}: ${error.message}`); } } } @@ -328,7 +329,15 @@ export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { } throw error; } - removeLegacyFiles(pkgRoot); - gcOldRevisions(pkgRoot, revision); + for (const cleanup of [ + () => removeLegacyFiles(pkgRoot), + () => gcOldRevisions(pkgRoot, revision), + ]) { + try { + cleanup(); + } catch (cleanupError) { + console.warn(`Published Oven ${id}, but cleanup failed: ${cleanupError.message}`); + } + } return pkgRoot; } diff --git a/src/server/fs-safe.test.mjs b/src/server/fs-safe.test.mjs index 4d5015a..a201e2a 100644 --- a/src/server/fs-safe.test.mjs +++ b/src/server/fs-safe.test.mjs @@ -46,17 +46,16 @@ function swapFailure(mode, parent) { '};', 'syncBuiltinESMExports();', 'const { atomicDirectory } = await import(moduleUrl);', - 'try { atomicDirectory(parent, "oven", { "instructions.md": "updated\\n" }, { replace: true, preserveExisting: true }); }', - 'catch (error) {', - ' process.stdout.write(JSON.stringify({ name: error.name, message: error.message, errors: error.errors?.length ?? 0, old: readdirSync(parent).filter((name) => name.startsWith(".oven.old.")), target: existsSync(target) }));', - '}', + 'let error = null;', + 'try { atomicDirectory(parent, "oven", { "instructions.md": "updated\\n" }, { replace: true, preserveExisting: true }); } catch (caught) { error = caught; }', + 'process.stdout.write(JSON.stringify({ name: error?.name, message: error?.message, errors: error?.errors?.length ?? 0, old: readdirSync(parent).filter((name) => name.startsWith(".oven.old.")), target: existsSync(target) }));', ].join("\n"); const result = spawnSync(process.execPath, ["--input-type=module", "--eval", script, parent, mode, new URL("./fs-safe.mjs", import.meta.url).href], { encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); } -test("atomicDirectory surfaces failed rollback and old-directory cleanup", () => { +test("atomicDirectory surfaces failed rollback but keeps a successful replacement live after cleanup failure", () => { const rollback = fixture(); const cleanup = fixture(); try { @@ -68,7 +67,7 @@ test("atomicDirectory surfaces failed rollback and old-directory cleanup", () => assert.equal(rollbackResult.old.length, 1); const cleanupResult = swapFailure("cleanup", cleanup.root); - assert.match(cleanupResult.message, /could not clean up/u); + assert.equal(cleanupResult.message, undefined); assert.equal(cleanupResult.target, true); assert.equal(cleanupResult.old.length, 1); } finally { @@ -77,6 +76,48 @@ test("atomicDirectory surfaces failed rollback and old-directory cleanup", () => } }); +test("Oven package cleanup failures do not undo a published revision, while pre-swap failures throw", () => { + const context = fixture(); + const script = [ + 'import fs, { existsSync, readFileSync } from "node:fs";', + 'import { syncBuiltinESMExports } from "node:module";', + 'const [root, moduleUrl] = process.argv.slice(1);', + 'const { atomicOvenPackage, resolveOvenPackageDir } = await import(moduleUrl);', + 'const files = (name) => ({ "instructions.md": "# " + name + "\\n", "detail.json": "{}\\n" });', + 'atomicOvenPackage(root, "oven", files("first"));', + 'const nativeReaddir = fs.readdirSync;', + 'fs.readdirSync = (path, ...rest) => path === root + "/oven" ? (() => { throw new Error("gc blocked"); })() : nativeReaddir(path, ...rest);', + 'syncBuiltinESMExports();', + 'atomicOvenPackage(root, "oven", files("second"), { replace: true });', + 'const current = resolveOvenPackageDir(root + "/oven");', + 'process.stdout.write(JSON.stringify({ current, contents: readFileSync(current + "/instructions.md", "utf8"), exists: existsSync(current) }));', + ].join("\n"); + const preSwapScript = [ + 'import fs from "node:fs";', + 'import { syncBuiltinESMExports } from "node:module";', + 'const [root, moduleUrl] = process.argv.slice(1);', + 'const { atomicOvenPackage } = await import(moduleUrl);', + 'const nativeWrite = fs.writeFileSync;', + 'fs.writeFileSync = (path, ...rest) => { if (String(path).includes("instructions.md")) throw new Error("stage blocked"); return nativeWrite(path, ...rest); };', + 'syncBuiltinESMExports();', + 'try { atomicOvenPackage(root, "pre-swap", { "instructions.md": "# blocked\\n", "detail.json": "{}\\n" }); } catch (error) { process.stdout.write(error.message); }', + ].join("\n"); + try { + const published = spawnSync(process.execPath, ["--input-type=module", "--eval", script, context.root, new URL("./fs-safe.mjs", import.meta.url).href], { encoding: "utf8" }); + assert.equal(published.status, 0, published.stderr); + const result = JSON.parse(published.stdout); + assert.equal(result.exists, true); + assert.match(result.current, /rev-[a-f0-9]+$/u); + assert.match(result.contents, /second/u); + + const failed = spawnSync(process.execPath, ["--input-type=module", "--eval", preSwapScript, context.root, new URL("./fs-safe.mjs", import.meta.url).href], { encoding: "utf8" }); + assert.equal(failed.status, 0, failed.stderr); + assert.match(failed.stdout, /stage blocked/u); + } finally { + context.cleanup(); + } +}); + function ovenFiles(version) { return { "instructions.md": `# Oven ${version}\n`, From 1f1df3aefb3bbe1a3e52c8c456a3ac07b5d4cf51 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 16:01:19 +0200 Subject: [PATCH 22/30] fix: oven write size guard, containment, per-oven isolation, blocked rows --- .../ProjectGroup/BurnlistRow.test.mjs | 42 +++++++ .../components/ProjectGroup/BurnlistRow.tsx | 32 ++--- dashboard/src/lib/hrefs.ts | 2 +- dashboard/src/lib/types.ts | 7 +- .../engine/differential-testing-handler.mjs | 3 +- scripts/verify.mjs | 3 + src/cli/oven-cli.mjs | 85 +++++++------- src/cli/oven-cli.test.mjs | 97 +++++++++++++-- src/server/burnlist-dashboard-server.mjs | 110 +++++++++--------- src/server/burnlist-discovery.mjs | 34 ++++++ src/server/burnlist-discovery.test.mjs | 26 +++++ src/server/dashboard-entry-isolation.mjs | 76 ++++++++++++ src/server/dashboard-entry-isolation.test.mjs | 32 +++++ src/server/dashboard-routes.test.mjs | 26 ++++- src/server/fs-safe.mjs | 5 +- src/server/fs-safe.test.mjs | 20 ++++ src/server/oven-bindings.mjs | 12 +- src/server/oven-bindings.test.mjs | 14 +++ src/server/oven-storage.mjs | 104 +++++++++++++++++ 19 files changed, 598 insertions(+), 132 deletions(-) create mode 100644 dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs create mode 100644 src/server/burnlist-discovery.mjs create mode 100644 src/server/burnlist-discovery.test.mjs create mode 100644 src/server/dashboard-entry-isolation.mjs create mode 100644 src/server/dashboard-entry-isolation.test.mjs create mode 100644 src/server/oven-storage.mjs diff --git a/dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs b/dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs new file mode 100644 index 0000000..c6032dc --- /dev/null +++ b/dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./BurnlistRow.tsx", import.meta.url).pathname; +const layoutPath = new URL("../../layout", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; + +test("a blocked row exposes its reason without rendering navigation semantics", async () => { + const outputDir = await mkdtemp(join(tmpdir(), "burnlist-row-test-")); + try { + const outputPath = join(outputDir, "BurnlistRow.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@layout": layoutPath, "@lib": libPath }, jsx: "automatic", target: "node18", + }); + const { BurnlistRow } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); + const entry = { + id: "blocked-checklist", repo: "fixture", repoKey: null, repoRoot: null, + planPath: null, planLabel: null, title: "Checklist data", status: "active", statusLabel: "Blocked", + total: 0, done: null, remaining: null, percent: null, errors: 1, warnings: 0, + updatedAt: null, lastCompletedAt: null, ovenId: "third-party-oven", ovenName: "Third party", + href: "/Oven/blocked-checklist", progressLabel: "Blocked", blockers: "The data binding is unavailable.", + }; + const row = BurnlistRow({ entry, filter: "active", ambiguous: false }); + const markup = renderToStaticMarkup(createElement("table", null, createElement("tbody", null, row))); + + assert.equal(row.props.onClick, undefined); + assert.equal(row.props.role, undefined); + assert.equal(row.props.tabIndex, undefined); + assert.match(markup, /Blocked: The data binding is unavailable\./u); + assert.match(markup, /data-blocked-reason="true"/u); + assert.doesNotMatch(markup, /(?:href=|role="link"|tabindex=)/u); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } +}); diff --git a/dashboard/src/components/ProjectGroup/BurnlistRow.tsx b/dashboard/src/components/ProjectGroup/BurnlistRow.tsx index 383f79e..1094b62 100644 --- a/dashboard/src/components/ProjectGroup/BurnlistRow.tsx +++ b/dashboard/src/components/ProjectGroup/BurnlistRow.tsx @@ -1,10 +1,11 @@ -import type { MouseEvent } from "react"; +import type { KeyboardEvent, MouseEvent } from "react"; import { Button, Progress } from "@layout"; import { burnlistHref, formatTime } from "@lib"; import type { Burnlist, Filter } from "@lib"; export function BurnlistRow({ entry, filter, ambiguous }: { entry: Burnlist; filter: Filter; ambiguous: boolean }) { - const href = entry.ovenId === "checklist" ? burnlistHref(entry, filter, ambiguous) : entry.href; + const blocked = entry.statusLabel === "Blocked"; + const href = blocked ? entry.href : entry.ovenId === "checklist" ? burnlistHref(entry, filter, ambiguous) : entry.href; const open = () => { window.location.href = href; }; const copy = (event: MouseEvent) => { event.stopPropagation(); @@ -18,25 +19,28 @@ export function BurnlistRow({ entry, filter, ambiguous }: { entry: Burnlist; fil return ( { - if (event.target !== event.currentTarget) return; - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - open(); - } - }} - role="link" - tabIndex={0} + {...(blocked ? {} : { + "aria-label": `Open ${entry.repo}/${entry.id}`, + onClick: open, + onKeyDown: (event: KeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + open(); + } + }, + role: "link", + tabIndex: 0, + })} >

{entry.repo}/{entry.id}

{entry.title}

- + {blocked &&

Blocked: {entry.blockers ?? entry.statusLabel}

} + {!blocked && } {entry.ovenName} {entry.statusLabel} diff --git a/dashboard/src/lib/hrefs.ts b/dashboard/src/lib/hrefs.ts index cb640f6..02cbc36 100644 --- a/dashboard/src/lib/hrefs.ts +++ b/dashboard/src/lib/hrefs.ts @@ -32,7 +32,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)}`; + 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)}`; diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 218f4e1..983b256 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -26,9 +26,9 @@ export type Burnlist = { repo: string; repoKey: string | null; repoRoot: string | null; - planPath: string; + planPath: string | null; title: string; - planLabel: string; + planLabel: string | null; status: Exclude; statusLabel: string; total: number; @@ -39,7 +39,8 @@ export type Burnlist = { warnings: number; updatedAt: string | null; lastCompletedAt: string | null; - ovenId: "checklist" | "differential-testing"; + /** Server-validated Oven identifier. */ + ovenId: string; ovenName: string; href: string; progressLabel: string; diff --git a/ovens/differential-testing/engine/differential-testing-handler.mjs b/ovens/differential-testing/engine/differential-testing-handler.mjs index 7db78d8..8773e38 100644 --- a/ovens/differential-testing/engine/differential-testing-handler.mjs +++ b/ovens/differential-testing/engine/differential-testing-handler.mjs @@ -188,6 +188,7 @@ export const differentialTestingHandler = Object.freeze({ : ctx.discoveredRepos().find((entry) => entry.repoKey === binding.repoKey)?.name ?? basename(binding.repoRoot); return index.scenarios.map((scenario) => ({ id: scenario.id, repo, repoKey: binding.repoKey, repoRoot: binding.repoRoot, title: scenario.label, + planPath: null, planLabel: null, 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", @@ -198,7 +199,7 @@ export const differentialTestingHandler = Object.freeze({ const repo = binding.repoKey === null ? "differential-testing" : basename(binding.repoRoot); return [{ id: `blocked-${binding.repoKey ?? "global"}`, repo, repoKey: binding.repoKey, repoRoot: binding.repoRoot, - title: "Differential Testing", planPath: "", planLabel: "Oven data binding", + 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", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index ab38e21..2b961a5 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -422,6 +422,8 @@ assertPublishablePackage(); run(process.execPath, [ "--test", "src/server/dashboard-routes.test.mjs", + "src/server/dashboard-entry-isolation.test.mjs", + "src/server/burnlist-discovery.test.mjs", "src/ovens/oven-contract.test.mjs", "src/ovens/oven-registry.test.mjs", "src/server/projects.test.mjs", @@ -443,6 +445,7 @@ run(process.execPath, [ "src/server/registry.test.mjs", "src/server/repo-map.test.mjs", "src/server/repo-state.test.mjs", + "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", "dashboard/src/lib/project-open.test.mjs", ]); diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index 67bbd14..3167ef7 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -8,17 +8,23 @@ // 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 { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; import { atomicOvenPackage, resolveOvenPackageDir, withOvenPackageLock } from "../server/fs-safe.mjs"; -import { containedJoin } from "../server/repo-state.mjs"; +import { + assertCustomOvensDir, + assertCustomOvenPath, + OVEN_DETAIL_MAX_BYTES, + OVEN_INSTRUCTIONS_MAX_BYTES, + OVEN_LINEAGE_MAX_BYTES, + resolveCustomOvensDir, + serializeOvenPackage, +} from "../server/oven-storage.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; -const MAX_INSTRUCTION_BYTES = 65536; -const MAX_DETAIL_BYTES = 131072; // ── argv ──────────────────────────────────────────────────────────────────── // process.argv is [node, bin/burnlist.mjs, "oven", , ...rest]. const tokens = process.argv.slice(2); @@ -61,27 +67,14 @@ function bindingRepo() { const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const builtInOvensDir = resolve(packageRoot, "ovens"); const launchCwd = process.cwd(); -const customOvensDir = flags.has("ovens-dir") - ? resolve(repoRoot(), flags.get("ovens-dir")) - : containedJoin(repoRoot(), "ovens"); - -function isWithin(parent, child) { - const pathFromParent = relative(parent, child); - return pathFromParent === "" - || (pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent)); -} - -function assertCustomOvenPath(root, id) { - const path = join(root, id); - try { - const ovensRoot = realpathSync(root); - const ovenRoot = realpathSync(path); - if (!isWithin(ovensRoot, ovenRoot)) throw new Error(`Custom Oven ${id} escapes ${root}.`); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - return path; -} +const customRepoRoot = repoRoot(); +if (flags.get("ovens-dir") === "true") fail("--ovens-dir requires a path."); +const unsafeOvensDir = flags.has("unsafe-ovens-dir"); +const customOvensDir = resolveCustomOvensDir( + customRepoRoot, + flags.has("ovens-dir") ? flags.get("ovens-dir") : undefined, + { unsafe: unsafeOvensDir }, +); function safeStat(path) { try { @@ -115,7 +108,7 @@ function readOvenDir(root, id, builtIn) { const safeId = ovenId(id); let ovenRoot; try { - const path = builtIn ? join(root, safeId) : assertCustomOvenPath(root, safeId); + const path = builtIn ? join(root, safeId) : assertCustomOvenPath(customRepoRoot, root, safeId, { unsafe: unsafeOvensDir }); ovenRoot = resolveOvenPackageDir(realpathSync(path)); } catch (error) { if (error?.code === "ENOENT") return null; @@ -127,15 +120,15 @@ function readOvenDir(root, id, builtIn) { if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; const ovenPackage = normalizeOvenPackage({ id: safeId, - instructions: readTextFileWithLimit(instructionsPath, MAX_INSTRUCTION_BYTES, "Oven instructions"), - detail: JSON.parse(readTextFileWithLimit(detailPath, MAX_DETAIL_BYTES, "Oven detail template")), + instructions: readTextFileWithLimit(instructionsPath, OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"), + detail: JSON.parse(readTextFileWithLimit(detailPath, OVEN_DETAIL_MAX_BYTES, "Oven detail template")), }); const lineagePath = join(ovenRoot, "oven.json"); let forkedFrom; if (safeStat(lineagePath)?.isFile()) { try { forkedFrom = normalizeOvenForkedFrom( - JSON.parse(readTextFileWithLimit(lineagePath, MAX_DETAIL_BYTES, "Oven lineage sidecar")), + JSON.parse(readTextFileWithLimit(lineagePath, OVEN_LINEAGE_MAX_BYTES, "Oven lineage sidecar")), ).forkedFrom; } catch (error) { throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); @@ -159,6 +152,7 @@ function readOvenDir(root, id, builtIn) { } function ovensIn(root, builtIn) { + if (!builtIn) assertCustomOvensDir(customRepoRoot, root, { unsafe: unsafeOvensDir }); let entries; try { entries = readdirSync(root, { withFileTypes: true }); @@ -169,7 +163,14 @@ function ovensIn(root, builtIn) { return entries .map((entry) => entry.name) .filter((id) => !id.startsWith(".") && /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id)) - .map((id) => readOvenDir(root, id, builtIn)) + .map((id) => { + try { + return readOvenDir(root, id, builtIn); + } catch (error) { + console.warn(`Ignoring malformed Oven ${id}: ${error.message}`); + return null; + } + }) .filter(Boolean); } @@ -217,17 +218,17 @@ function readInput(spec, maxBytes, label) { function resolvePackageInput() { const pkg = {}; if (flags.has("package")) { - Object.assign(pkg, JSON.parse(readInput(flags.get("package"), MAX_DETAIL_BYTES, "Oven package"))); + Object.assign(pkg, JSON.parse(readInput(flags.get("package"), OVEN_DETAIL_MAX_BYTES, "Oven package"))); } if (flags.has("dir")) { const dir = resolve(flags.get("dir")); const instructionsPath = join(dir, "instructions.md"); const detailPath = join(dir, "detail.json"); - pkg.instructions = readTextFileWithLimit(instructionsPath, MAX_INSTRUCTION_BYTES, "Oven instructions"); - pkg.detail = JSON.parse(readTextFileWithLimit(detailPath, MAX_DETAIL_BYTES, "Oven detail template")); + pkg.instructions = readTextFileWithLimit(instructionsPath, OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); + pkg.detail = JSON.parse(readTextFileWithLimit(detailPath, OVEN_DETAIL_MAX_BYTES, "Oven detail template")); } - if (flags.has("instructions")) pkg.instructions = readInput(flags.get("instructions"), MAX_INSTRUCTION_BYTES, "Oven instructions"); - if (flags.has("detail")) pkg.detail = JSON.parse(readInput(flags.get("detail"), MAX_DETAIL_BYTES, "Oven detail template")); + if (flags.has("instructions")) pkg.instructions = readInput(flags.get("instructions"), OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); + if (flags.has("detail")) pkg.detail = JSON.parse(readInput(flags.get("detail"), OVEN_DETAIL_MAX_BYTES, "Oven detail template")); const id = ovenId(positionals[0] ?? pkg.id ?? flags.get("id") ?? ""); const name = flags.has("name") ? String(flags.get("name")).trim() : String(pkg.name ?? "").trim(); @@ -250,15 +251,14 @@ function resolvePackageInput() { } function persistOven(pkg, { allowReplace, sidecar }) { - const files = { - "instructions.md": `${pkg.instructions}\n`, - "detail.json": `${JSON.stringify(pkg.detail, null, 2)}\n`, - ...(sidecar ? { "oven.json": `${JSON.stringify(sidecar, null, 2)}\n` } : {}), - }; + const files = serializeOvenPackage({ ...pkg, sidecar }); try { - assertCustomOvenPath(customOvensDir, pkg.id); + assertCustomOvenPath(customRepoRoot, customOvensDir, pkg.id, { unsafe: unsafeOvensDir }); return withOvenPackageLock(customOvensDir, pkg.id, () => ( - atomicOvenPackage(customOvensDir, pkg.id, files, { replace: allowReplace }) + atomicOvenPackage(customOvensDir, pkg.id, files, { + replace: allowReplace, + assertPath: () => assertCustomOvenPath(customRepoRoot, customOvensDir, pkg.id, { unsafe: unsafeOvensDir }), + }) )); } catch (error) { if (!allowReplace && error.message === `${pkg.id} already exists.`) { @@ -299,6 +299,7 @@ Options: --package

JSON package file, or - for stdin. --repo

Repository whose local Oven bindings to use. --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. --json Machine-readable output for list/view. diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index 9771f0d..da4041d 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -6,9 +6,11 @@ import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { ovenRevision } from "../ovens/oven-contract.mjs"; import { resolveOvenPackageDir } from "../server/fs-safe.mjs"; +import { assertCustomOvenPath } from "../server/oven-storage.mjs"; const repoRoot = resolve(new URL("../..", import.meta.url).pathname); const binPath = join(repoRoot, "bin", "burnlist.mjs"); +const serverPath = join(repoRoot, "src", "server", "burnlist-dashboard-server.mjs"); function fixture() { const root = mkdtempSync(join(tmpdir(), "burnlist-oven-cli-")); @@ -104,7 +106,7 @@ test("oven bind, bindings, and unbind persist a logical repo-local binding", () test("oven view reads optional fork lineage and rejects malformed sidecars", () => { const context = fixture(); - const ovensDir = join(context.repo, "ovens"); + const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); try { writeOven(ovensDir, "forked-oven", JSON.stringify({ forkedFrom: { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }, @@ -123,9 +125,25 @@ test("oven view reads optional fork lineage and rejects malformed sidecars", () } finally { context.cleanup(); } }); +test("oven list warns and omits a malformed package while view stays fail-closed", () => { + const context = fixture(); + const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); + try { + writeOven(ovensDir, "healthy-oven"); + writeOven(ovensDir, "broken-oven", "{"); + const listed = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)); + assert.equal(listed.some((oven) => oven.id === "healthy-oven"), true); + assert.equal(listed.some((oven) => oven.id === "broken-oven"), false); + assert.throws( + () => run(context, "oven", "view", "broken-oven", "--json", "--ovens-dir", ovensDir), + (error) => String(error.stderr).includes("lineage sidecar is invalid"), + ); + } finally { context.cleanup(); } +}); + test("oven create and update swap the package while preserving sibling files", () => { const context = fixture(); - const ovensDir = join(context.repo, "ovens"); + const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); try { const packagePath = join(context.repo, "replacement.json"); writeFileSync(packagePath, JSON.stringify({ @@ -149,7 +167,7 @@ test("oven create and update swap the package while preserving sibling files", ( test("oven update migrates a legacy plain directory to a pointer package", () => { const context = fixture(); - const ovensDir = join(context.repo, "ovens"); + const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); try { writeOven(ovensDir, "legacy-oven", JSON.stringify({ forkedFrom: { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }, @@ -183,6 +201,71 @@ test("oven storage follows the umbrella root when launched from a subdirectory", } finally { context.cleanup(); } }); +test("oven publication rejects oversized stored bytes without replacing a readable package", () => { + const context = fixture(); + const detailPath = join(context.repo, "detail.json"); + const instructionsPath = join(context.repo, "instructions.md"); + const ovenPath = join(context.repo, ".local", "burnlist", "ovens", "sized-oven"); + try { + writeFileSync(detailPath, JSON.stringify(detailFixture())); + writeFileSync(instructionsPath, `# ${"x".repeat(65_534)}`); + assert.throws( + () => run(context, "oven", "create", "sized-oven", "--instructions", instructionsPath, "--detail", detailPath), + (error) => String(error.stderr).includes("instructions.md") && String(error.stderr).includes("65536 byte limit"), + ); + assert.equal(existsSync(ovenPath), false); + + writeFileSync(instructionsPath, `# ${"x".repeat(65_533)}`); + run(context, "oven", "create", "sized-oven", "--instructions", instructionsPath, "--detail", detailPath); + const priorPointer = readFileSync(join(ovenPath, "current"), "utf8"); + const priorInstructions = readFileSync(join(resolveOvenPackageDir(ovenPath), "instructions.md"), "utf8"); + assert.equal(Buffer.byteLength(priorInstructions), 65_536); + + writeFileSync(instructionsPath, `# ${"y".repeat(65_534)}`); + assert.throws( + () => run(context, "oven", "update", "sized-oven", "--instructions", instructionsPath, "--detail", detailPath), + (error) => String(error.stderr).includes("instructions.md") && String(error.stderr).includes("65536 byte limit"), + ); + assert.equal(readFileSync(join(ovenPath, "current"), "utf8"), priorPointer); + assert.equal(readFileSync(join(resolveOvenPackageDir(ovenPath), "instructions.md"), "utf8"), priorInstructions); + } finally { context.cleanup(); } +}); + +test("oven storage rejects escaped overrides and catches a later local-state symlink", () => { + const context = fixture(); + const packagePath = join(context.repo, "package.json"); + const outside = join(dirname(context.repo), "outside"); + try { + writeFileSync(packagePath, JSON.stringify({ instructions: "# Contained Oven\n\nStay in the repo.", detail: detailFixture() })); + mkdirSync(outside); + assert.throws( + () => run(context, "oven", "create", "escaped-oven", "--package", packagePath, "--ovens-dir", outside), + (error) => String(error.stderr).includes("escapes repo state"), + ); + assert.throws( + () => execFileSync(process.execPath, [serverPath, "--stamp", "--ovens-dir", outside], { cwd: context.repo, encoding: "utf8" }), + (error) => String(error.stderr).includes("escapes repo state"), + ); + run(context, "oven", "create", "allowed-oven", "--package", packagePath, "--ovens-dir", outside, "--unsafe-ovens-dir"); + assert.match( + execFileSync(process.execPath, [serverPath, "--stamp", "--ovens-dir", outside, "--unsafe-ovens-dir"], { cwd: context.repo, encoding: "utf8" }), + /^\d{4}-\d{2}-\d{2}T/u, + ); + assert.equal(existsSync(join(outside, "allowed-oven", "current")), true); + + run(context, "oven", "create", "local-oven", "--package", packagePath); + const defaultOvens = join(context.repo, ".local", "burnlist", "ovens"); + assertCustomOvenPath(context.repo, defaultOvens, "local-oven"); + rmSync(join(context.repo, ".local"), { recursive: true, force: true }); + symlinkSync(outside, join(context.repo, ".local"), "dir"); + assert.throws(() => assertCustomOvenPath(context.repo, defaultOvens, "local-oven"), /escapes repo state/u); + assert.throws( + () => run(context, "oven", "view", "local-oven", "--json"), + (error) => String(error.stderr).includes("escapes repo state"), + ); + } finally { context.cleanup(); } +}); + test("oven storage rejects symlink escapes for its state directory and individual Oven ids", () => { const context = fixture(); const packagePath = join(context.repo, "package.json"); @@ -220,7 +303,7 @@ test("oven storage rejects symlink escapes for its state directory and individua test("oven view and list read complete packages on both sides of a publish barrier", async () => { const context = fixture(); - const ovensDir = join(context.repo, "ovens"); + const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); try { const packagePath = join(context.repo, "replacement.json"); writeFileSync(packagePath, JSON.stringify({ @@ -249,7 +332,7 @@ test("oven view and list read complete packages on both sides of a publish barri test("a killed writer leaves the prior pointer package readable", async () => { const context = fixture(); - const ovensDir = join(context.repo, "ovens"); + const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); try { const packagePath = join(context.repo, "replacement.json"); writeFileSync(packagePath, JSON.stringify({ instructions: "# Initial Oven\n\nInitial checklist.", detail: detailFixture() })); @@ -274,7 +357,7 @@ test("a killed writer leaves the prior pointer package readable", async () => { test("oven fork writes source revision lineage and discovery exposes it", () => { const context = fixture(); - const ovensDir = join(context.repo, "ovens"); + const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); try { writeOven(ovensDir, "source-oven"); const source = JSON.parse(run(context, "oven", "view", "source-oven", "--json", "--ovens-dir", ovensDir)); @@ -292,7 +375,7 @@ test("oven fork writes source revision lineage and discovery exposes it", () => test("oven ignores legacy files and accepts only the two-file package layout", () => { const context = fixture(); - const ovensDir = join(context.repo, "ovens"); + const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); const legacyDir = join(ovensDir, "legacy-only"); try { mkdirSync(legacyDir, { recursive: true }); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index d34101b..3536b79 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -11,7 +11,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import os from "node:os"; import { mutatorRepoRoots, observerRepoRoots } from "./discovery.mjs"; @@ -33,7 +33,18 @@ import "../ovens/built-in-handlers.mjs"; import { getOvenHandler, listOvenHandlers } from "../ovens/oven-registry.mjs"; import { genericJsonHandler } from "../ovens/handlers/generic-json-handler.mjs"; import { buildRepoMapAsync } from "./repo-map.mjs"; +import { discoverBurnlistSummaries } from "./burnlist-discovery.mjs"; +import { isolatedDashboardEntries } from "./dashboard-entry-isolation.mjs"; import { atomicDirectory, atomicOvenPackage, readTextFileWithLimit, resolveOvenPackageDir, safeStat, withOvenPackageLock } from "./fs-safe.mjs"; +import { + assertCustomOvensDir, + assertCustomOvenPath, + OVEN_DETAIL_MAX_BYTES, + OVEN_INSTRUCTIONS_MAX_BYTES, + OVEN_LINEAGE_MAX_BYTES, + resolveCustomOvensDir, + serializeOvenPackage, +} from "./oven-storage.mjs"; import { warmOvenHandler } from "./oven-warm.mjs"; import { LIFECYCLES, @@ -44,7 +55,6 @@ import { lifecycleForPlan, localIsoTimestamp, parsePlan, - summaryForPlan, twoDigit, validatePlan, } from "./plan-model.mjs"; @@ -69,6 +79,7 @@ const allowedArgs = new Set([ "stamp", "state-dir", "stop", + "unsafe-ovens-dir", ]); for (let index = 2; index < process.argv.length; index += 1) { const arg = process.argv[index]; @@ -108,9 +119,16 @@ const dashboardDistDir = resolve(packageRoot, "dashboard", "dist"); const dashboardIndexPath = resolve(dashboardDistDir, "index.html"); const builtInOvensDir = resolve(packageRoot, "ovens"); const umbrellaRoot = resolveUmbrella(launchCwd); -const customOvensDir = args.has("ovens-dir") - ? resolve(umbrellaRoot, args.get("ovens-dir")) - : containedJoin(umbrellaRoot, "ovens"); +if (args.get("ovens-dir") === "true") { + console.error("--ovens-dir requires a path."); + process.exit(2); +} +const unsafeOvensDir = args.has("unsafe-ovens-dir"); +const customOvensDir = resolveCustomOvensDir( + umbrellaRoot, + args.has("ovens-dir") ? args.get("ovens-dir") : undefined, + { unsafe: unsafeOvensDir }, +); const legacyRunsDir = args.has("runs-dir") ? resolve(launchCwd, args.get("runs-dir")) : null; const ovenDataOverrides = parseOvenDataBindings(args.get("oven-data") ?? ""); const writeToken = randomBytes(24).toString("hex"); @@ -251,7 +269,13 @@ function burnlistPathsFor(repoRoots) { for (const lifecycle of LIFECYCLES) { const lifecycleRoot = join(repoRoot, "notes", "burnlists", lifecycle.folder); if (!safeStat(lifecycleRoot)?.isDirectory()) continue; - for (const id of readdirSync(lifecycleRoot)) { + let ids; + try { + ids = readdirSync(lifecycleRoot); + } catch { + continue; + } + for (const id of ids) { if (id.startsWith(".")) continue; const planPath = join(lifecycleRoot, id, "burnlist.md"); if (safeStat(planPath)?.isFile()) paths.push(planPath); @@ -266,35 +290,22 @@ function burnlistPaths() { } function discoverBurnlists() { - return burnlistPaths().map((path) => summaryForPlan(path, maxPlanBytes)); + return discoverBurnlistSummaries({ repoRoots: candidateRepoRoots(), maxPlanBytes }); } -function dashboardEntries(ovenDataBindings = resolvedOvenDataBindings()) { - const entries = []; - for (const handler of listOvenHandlers()) { - try { - entries.push(...(handler.dashboardEntries?.(ovenHandlerContext({ id: handler.id, oven: { id: handler.id }, ovenDataBindings })) ?? [])); - } catch (error) { - entries.push(blockedDashboardEntry(handler, error)); - } - } - return entries - .map((entry) => { - let key = null; - try { - key = entry.repoRoot ? repoKey(realpathSync(entry.repoRoot)) : null; - } catch { - // Entries for unavailable roots remain visible without a route key. - } - return { ...entry, repoKey: key }; - }) - .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))); +function dashboardEntries(ovenDataBindings) { + return isolatedDashboardEntries({ + handlers: listOvenHandlers(), + contextForHandler: (handler) => ovenHandlerContext({ id: handler.id, oven: { id: handler.id }, ovenDataBindings }), + repoKeyForRoot: (root) => repoKey(realpathSync(root)), + blockedEntry: blockedDashboardEntry, + }); } function blockedDashboardEntry(handler, error) { const blockers = String(error?.message ?? error ?? "Oven dashboard data is unavailable.").slice(0, 200); return { - id: `blocked-${handler.id}`, repo: "Oven", repoKey: null, repoRoot: null, planPath: "", + id: `blocked-${handler.id}`, repo: "Oven", repoKey: null, repoRoot: null, planPath: null, title: handler.id, planLabel: "Oven dashboard", status: "active", statusLabel: "Blocked", total: 0, done: null, remaining: null, percent: null, errors: 1, warnings: 0, lastCompletedAt: null, updatedAt: null, ovenId: handler.id, ovenName: handler.id, @@ -320,7 +331,7 @@ function ovenHandlerContext({ id, oven, req, res, url, bindingPath, ovenDataBind }; } -function projectsSnapshot(ovenDataBindings = resolvedOvenDataBindings()) { +function projectsSnapshot(ovenDataBindings) { const home = os.homedir(); const hasScanRootOverride = Boolean(args.get("scan-root")); let registeredRoots = []; @@ -387,7 +398,7 @@ function readOven(root, id, builtIn) { const safeId = ovenId(id); let ovenRoot; try { - const path = builtIn ? join(root, safeId) : assertCustomOvenPath(root, safeId); + const path = builtIn ? join(root, safeId) : assertCustomOvenPath(umbrellaRoot, root, safeId, { unsafe: unsafeOvensDir }); ovenRoot = resolveOvenPackageDir(realpathSync(path)); } catch (error) { if (error?.code === "ENOENT") return null; @@ -399,15 +410,15 @@ function readOven(root, id, builtIn) { if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; const ovenPackage = normalizeOvenPackage({ id: safeId, - instructions: readTextFileWithLimit(instructionsPath, 65536, "Oven instructions"), - detail: JSON.parse(readTextFileWithLimit(detailPath, 131072, "Oven detail template")), + instructions: readTextFileWithLimit(instructionsPath, OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"), + detail: JSON.parse(readTextFileWithLimit(detailPath, OVEN_DETAIL_MAX_BYTES, "Oven detail template")), }); const lineagePath = join(ovenRoot, "oven.json"); let forkedFrom; if (safeStat(lineagePath)?.isFile()) { try { forkedFrom = normalizeOvenForkedFrom( - JSON.parse(readTextFileWithLimit(lineagePath, 131072, "Oven lineage sidecar")), + JSON.parse(readTextFileWithLimit(lineagePath, OVEN_LINEAGE_MAX_BYTES, "Oven lineage sidecar")), ).forkedFrom; } catch (error) { throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); @@ -430,6 +441,7 @@ function readOven(root, id, builtIn) { } function ovensIn(root, builtIn) { + if (!builtIn) assertCustomOvensDir(umbrellaRoot, root, { unsafe: unsafeOvensDir }); let entries; try { entries = readdirSync(root, { withFileTypes: true }); @@ -451,24 +463,6 @@ function ovensIn(root, builtIn) { .filter(Boolean); } -function isWithin(parent, child) { - const pathFromParent = relative(parent, child); - return pathFromParent === "" - || (pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent)); -} - -function assertCustomOvenPath(root, id) { - const path = join(root, id); - try { - const ovensRoot = realpathSync(root); - const ovenRoot = realpathSync(path); - if (!isWithin(ovensRoot, ovenRoot)) throw new Error(`Custom Oven ${id} escapes ${root}.`); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - return path; -} - function discoverOvens() { const byId = new Map(); for (const oven of ovensIn(builtInOvensDir, true)) byId.set(oven.id, oven); @@ -512,10 +506,10 @@ function createOven(value) { instructions = instructionLines.join("\n"); const detail = normalizeOvenDetail(value.detail); const ovenPackage = normalizeOvenPackage({ id, instructions, detail }); - assertCustomOvenPath(customOvensDir, id); - const path = withOvenPackageLock(customOvensDir, id, () => atomicOvenPackage(customOvensDir, id, { - "instructions.md": `${ovenPackage.instructions}\n`, - "detail.json": `${JSON.stringify(ovenPackage.detail, null, 2)}\n`, + const files = serializeOvenPackage(ovenPackage); + assertCustomOvenPath(umbrellaRoot, customOvensDir, id, { unsafe: unsafeOvensDir }); + const path = withOvenPackageLock(customOvensDir, id, () => atomicOvenPackage(customOvensDir, id, files, { + assertPath: () => assertCustomOvenPath(umbrellaRoot, customOvensDir, id, { unsafe: unsafeOvensDir }), })); return { ...readOven(customOvensDir, id, false), path }; } @@ -985,7 +979,6 @@ const server = createServer(async (req, res) => { try { const url = new URL(req.url ?? "/", `http://${host}`); const method = req.method ?? "GET"; - const ovenDataBindings = resolvedOvenDataBindings(); const dashboardAsset = dashboardAssetPath(url.pathname); if (dashboardAsset) { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); @@ -997,12 +990,12 @@ const server = createServer(async (req, res) => { } if (url.pathname === "/api/projects") { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); - json(res, 200, projectsSnapshot(ovenDataBindings)); + json(res, 200, projectsSnapshot(resolvedOvenDataBindings())); return; } if (url.pathname === "/api/burnlists") { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); - json(res, 200, { generatedAt: new Date().toISOString(), burnlists: dashboardEntries(ovenDataBindings) }); + json(res, 200, { generatedAt: new Date().toISOString(), burnlists: dashboardEntries(resolvedOvenDataBindings()) }); return; } if (url.pathname === "/api/progress") { @@ -1045,6 +1038,7 @@ const server = createServer(async (req, res) => { if (method !== "GET") return json(res, 405, { error: "method not allowed" }); const id = ovenDataRoute[1]; const handler = getOvenHandler(id); + const ovenDataBindings = resolvedOvenDataBindings(); const binding = selectedOvenDataBinding(ovenDataBindings, id, url); let oven = null; if (!handler) { diff --git a/src/server/burnlist-discovery.mjs b/src/server/burnlist-discovery.mjs new file mode 100644 index 0000000..e1e37be --- /dev/null +++ b/src/server/burnlist-discovery.mjs @@ -0,0 +1,34 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { safeStat } from "./fs-safe.mjs"; +import { LIFECYCLES, summaryForPlan } from "./plan-model.mjs"; + +// Discovery is deliberately best-effort: a broken lifecycle directory or plan +// must not make every other repository disappear from the observer dashboard. +export function discoverBurnlistSummaries({ repoRoots, maxPlanBytes, lifecycles = LIFECYCLES } = {}) { + const entries = []; + for (const repoRoot of repoRoots ?? []) { + for (const lifecycle of lifecycles) { + const lifecycleRoot = join(repoRoot, "notes", "burnlists", lifecycle.folder); + if (!safeStat(lifecycleRoot)?.isDirectory()) continue; + let ids; + try { + ids = readdirSync(lifecycleRoot); + } catch { + continue; + } + for (const id of ids) { + if (id.startsWith(".")) continue; + const planPath = join(lifecycleRoot, id, "burnlist.md"); + if (!safeStat(planPath)?.isFile()) continue; + try { + entries.push(summaryForPlan(planPath, maxPlanBytes)); + } catch { + // summaryForPlan normally turns malformed plans into blocked rows. This + // final guard also keeps an unexpected per-plan filesystem failure local. + } + } + } + } + return entries.sort((left, right) => left.planPath.localeCompare(right.planPath)); +} diff --git a/src/server/burnlist-discovery.test.mjs b/src/server/burnlist-discovery.test.mjs new file mode 100644 index 0000000..daf2385 --- /dev/null +++ b/src/server/burnlist-discovery.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { discoverBurnlistSummaries } from "./burnlist-discovery.mjs"; + +function validPlan(title) { + return `# ${title}\n\n## Active Checklist\n\n- [ ] ISO-01 | Keep discovery isolated\n\n## Completed\n`; +} + +test("Burnlist discovery keeps healthy plans when another plan is malformed", () => { + const root = mkdtempSync(join(tmpdir(), "burnlist-discovery-")); + try { + const lifecycle = join(root, "notes", "burnlists", "inprogress"); + mkdirSync(join(lifecycle, "healthy"), { recursive: true }); + mkdirSync(join(lifecycle, "broken"), { recursive: true }); + writeFileSync(join(lifecycle, "healthy", "burnlist.md"), validPlan("Healthy")); + writeFileSync(join(lifecycle, "broken", "burnlist.md"), "\0"); + const summaries = discoverBurnlistSummaries({ repoRoots: [root], maxPlanBytes: 1024 }); + assert.equal(summaries.some((entry) => entry.id === "healthy"), true); + assert.ok((summaries.find((entry) => entry.id === "broken")?.errors ?? 0) > 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/server/dashboard-entry-isolation.mjs b/src/server/dashboard-entry-isolation.mjs new file mode 100644 index 0000000..77d2d6a --- /dev/null +++ b/src/server/dashboard-entry-isolation.mjs @@ -0,0 +1,76 @@ +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requiredText(value, field) { + if (typeof value !== "string" || !value.trim()) throw new Error(`Dashboard row ${field} must be a non-empty string.`); + return value; +} + +function nullableText(value, field) { + if (value == null) return null; + if (typeof value !== "string") throw new Error(`Dashboard row ${field} must be a string or null.`); + return value; +} + +function nullableRequiredText(value, field) { + if (value == null) return null; + if (typeof value !== "string" || !value.trim()) throw new Error(`Dashboard row ${field} must be a non-empty string or null.`); + return value; +} + +function count(value, field, { nullable = false } = {}) { + if (value == null && nullable) return null; + if (!Number.isInteger(value) || value < 0) throw new Error(`Dashboard row ${field} must be a non-negative integer${nullable ? " or null" : ""}.`); + return value; +} + +function dashboardRow(row, repoKeyForRoot) { + if (!isRecord(row)) throw new Error("Dashboard handler returned a malformed row."); + const repoRoot = row.repoRoot == null ? null : requiredText(row.repoRoot, "repoRoot"); + let key = row.repoKey == null ? null : requiredText(row.repoKey, "repoKey"); + if (repoRoot) key = repoKeyForRoot(repoRoot); + return { + ...row, + id: requiredText(row.id, "id"), + repo: requiredText(row.repo, "repo"), + repoKey: key, + repoRoot, + planPath: nullableRequiredText(row.planPath, "planPath"), + planLabel: nullableRequiredText(row.planLabel, "planLabel"), + title: requiredText(row.title, "title"), + status: requiredText(row.status, "status"), + statusLabel: requiredText(row.statusLabel, "statusLabel"), + total: count(row.total, "total"), + done: count(row.done, "done", { nullable: true }), + remaining: count(row.remaining, "remaining", { nullable: true }), + percent: count(row.percent, "percent", { nullable: true }), + errors: count(row.errors, "errors"), + warnings: count(row.warnings, "warnings"), + lastCompletedAt: nullableText(row.lastCompletedAt, "lastCompletedAt"), + updatedAt: nullableText(row.updatedAt, "updatedAt"), + ovenId: requiredText(row.ovenId, "ovenId"), + ovenName: requiredText(row.ovenName, "ovenName"), + href: requiredText(row.href, "href"), + progressLabel: requiredText(row.progressLabel, "progressLabel"), + ...(row.blockers == null ? {} : { blockers: requiredText(row.blockers, "blockers") }), + }; +} + +// A handler's call, row normalization, repo key derivation, and sorting share one +// boundary: no malformed adapter result can poison another Oven's dashboard rows. +export function isolatedDashboardEntries({ handlers, contextForHandler, repoKeyForRoot, blockedEntry }) { + const entries = []; + for (const handler of handlers) { + try { + const rows = handler.dashboardEntries?.(contextForHandler(handler)) ?? []; + if (!Array.isArray(rows)) throw new Error("Dashboard handler must return an array of rows."); + entries.push(...rows.map((row) => dashboardRow(row, repoKeyForRoot)).sort( + (left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")), + )); + } catch (error) { + entries.push(blockedEntry(handler, error)); + } + } + return entries.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))); +} diff --git a/src/server/dashboard-entry-isolation.test.mjs b/src/server/dashboard-entry-isolation.test.mjs new file mode 100644 index 0000000..79caba6 --- /dev/null +++ b/src/server/dashboard-entry-isolation.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isolatedDashboardEntries } from "./dashboard-entry-isolation.mjs"; + +function validRow(id) { + return { + id, repo: "fixture", repoKey: null, repoRoot: null, planPath: null, planLabel: null, title: id, + status: "active", statusLabel: "Active", total: 1, done: 0, remaining: 1, percent: 0, + errors: 0, warnings: 0, lastCompletedAt: null, updatedAt: null, ovenId: id, ovenName: id, + href: "/", progressLabel: "0/1 done", + }; +} + +test("dashboard row normalization blocks malformed handler rows without hiding healthy handlers", () => { + const handlers = [ + { id: "null-row", dashboardEntries: () => [null] }, + { id: "missing-field", dashboardEntries: () => [{ id: "missing-field" }] }, + { id: "healthy", dashboardEntries: () => [validRow("healthy")] }, + ]; + const entries = isolatedDashboardEntries({ + handlers, + contextForHandler: () => ({}), + repoKeyForRoot: (root) => root, + blockedEntry: (handler, error) => ({ ...validRow(`blocked-${handler.id}`), ovenId: handler.id, blockers: error.message }), + }); + assert.equal(entries.some((entry) => entry.id === "healthy"), true); + assert.deepEqual(entries.filter((entry) => entry.id.startsWith("blocked-")).map((entry) => entry.ovenId).sort(), [ + "missing-field", "null-row", + ]); + assert.deepEqual(entries.find((entry) => entry.id === "healthy")?.planPath, null); + assert.deepEqual(entries.find((entry) => entry.id === "healthy")?.planLabel, null); +}); diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 62515ef..164ff12 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -80,6 +80,26 @@ test("/api/burnlists lists discovered Burnlists across the observer set", { time }); }); +test("unreadable binding stores do not affect unrelated routes and healthy Oven data", { timeout: 20_000 }, async () => { + await withServer({ + burnlists: [{ repoPath: "bad" }, { repoPath: "good" }], + ovenData: [{ id: "checklist", payload: { source: "good" }, repoPath: "good", persisted: true, override: false }], + setup: async ({ fixtureRoot }) => { + await mkdir(join(fixtureRoot, "bad", ".local", "burnlist", "bindings.json"), { recursive: true }); + }, + }, async ({ baseUrl }) => { + assert.equal((await httpGet(baseUrl, "/")).status, 200); + assert.equal((await httpGet(baseUrl, "/favicon.svg")).status, 200); + assert.equal((await httpGet(baseUrl, "/api/progress?repo=bad&id=fixture")).status, 200); + const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; + const good = entries.find((entry) => entry.ovenId === "checklist" && entry.repo === "good"); + assert.ok(good); + const data = await httpGet(baseUrl, `/api/oven-data/checklist?repoKey=${good.repoKey}`); + assert.equal(data.status, 200); + assert.deepEqual(JSON.parse(data.body).payload, { source: "good" }); + }); +}); + test("registered Oven routes and dashboard entries isolate malformed custom Oven packages", { timeout: 20_000 }, async () => { const timestamp = "2026-01-01T12:00:00.000Z"; const differentialTestingPayload = buildPayload( @@ -167,7 +187,11 @@ test("Differential Testing bindings remain distinct for each repository", { time .filter((entry) => entry.ovenId === "differential-testing"); assert.equal(entries.length, 2); assert.equal(new Set(entries.map((entry) => entry.repoKey)).size, 2); - for (const entry of entries) assert.match(entry.href, new RegExp(`^/ovens/differential-testing/view\\?scenario=${entry.id}&repoKey=${entry.repoKey}$`, "u")); + 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")); + } const firstEntry = entries.find((entry) => entry.title === "first-repo"); const secondEntry = entries.find((entry) => entry.title === "second-repo"); diff --git a/src/server/fs-safe.mjs b/src/server/fs-safe.mjs index 0729d39..d6aa03a 100644 --- a/src/server/fs-safe.mjs +++ b/src/server/fs-safe.mjs @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { cpSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; +import { assertOvenPackageFileLimits } from "./oven-storage.mjs"; export const OVEN_REV_GRACE_MS = 60_000; @@ -299,7 +300,9 @@ function gcOldRevisions(pkgRoot, current) { // replacing a small pointer file. A reader that resolved the old revision keeps // a stable path until grace-period GC makes it eligible for deletion. A reader // suspended beyond that grace period mid-read is out of scope for this localhost tool. -export function atomicOvenPackage(parent, id, files, { replace = false } = {}) { +export function atomicOvenPackage(parent, id, files, { replace = false, assertPath } = {}) { + assertOvenPackageFileLimits(files); + assertPath?.(); mkdirSync(parent, { recursive: true }); const pkgRoot = join(parent, id); mkdirSync(pkgRoot, { recursive: true }); diff --git a/src/server/fs-safe.test.mjs b/src/server/fs-safe.test.mjs index a201e2a..83982cc 100644 --- a/src/server/fs-safe.test.mjs +++ b/src/server/fs-safe.test.mjs @@ -163,6 +163,26 @@ test("Oven pointer swaps measure revision GC grace from retirement", () => { } }); +test("Oven publication runs its locked path guard before creating a revision", () => { + const context = fixture(); + const target = join(context.root, "guarded"); + try { + withOvenPackageLock(context.root, "guarded", () => atomicOvenPackage(context.root, "guarded", ovenFiles("old"))); + const pointer = readFileSync(join(target, "current"), "utf8"); + assert.throws( + () => withOvenPackageLock(context.root, "guarded", () => atomicOvenPackage(context.root, "guarded", ovenFiles("new"), { + replace: true, + assertPath: () => { throw new Error("custom storage changed"); }, + })), + /custom storage changed/u, + ); + assert.equal(readFileSync(join(target, "current"), "utf8"), pointer); + assert.equal(readFileSync(join(resolveOvenPackageDir(target), "instructions.md"), "utf8"), "# Oven old\n"); + } finally { + context.cleanup(); + } +}); + test("Oven readers only fall back for an absent current pointer", () => { const context = fixture(); try { diff --git a/src/server/oven-bindings.mjs b/src/server/oven-bindings.mjs index e4cde47..371f968 100644 --- a/src/server/oven-bindings.mjs +++ b/src/server/oven-bindings.mjs @@ -130,10 +130,14 @@ function cachedStore(repoRoot, stat) { export function effectiveBindings({ repoRoots = [], override = new Map(), statFn = statSync } = {}) { const bindings = new Map(); for (const repoRoot of [...new Set(repoRoots)].sort((left, right) => left.localeCompare(right))) { - for (const [id, binding] of Object.entries(cachedStore(repoRoot, statFn).bindings)) { - const entries = bindings.get(id) ?? []; - entries.push({ repoKey: repoKey(repoRoot), repoRoot, path: resolve(repoRoot, binding.path) }); - bindings.set(id, entries); + try { + for (const [id, binding] of Object.entries(cachedStore(repoRoot, statFn).bindings)) { + const entries = bindings.get(id) ?? []; + entries.push({ repoKey: repoKey(repoRoot), repoRoot, path: resolve(repoRoot, binding.path) }); + bindings.set(id, entries); + } + } catch (error) { + console.warn(`Ignoring unavailable Oven binding store: ${bindingStorePath(repoRoot)} (${error.message})`); } } for (const [id, path] of override) { diff --git a/src/server/oven-bindings.test.mjs b/src/server/oven-bindings.test.mjs index 6c5c58b..a4b182d 100644 --- a/src/server/oven-bindings.test.mjs +++ b/src/server/oven-bindings.test.mjs @@ -99,3 +99,17 @@ test("effective bindings retain every repository binding and append global overr assert.equal(overridden.at(-1).repoKey, null); } finally { cleanup(); } }); + +test("effective bindings skip an unreadable repository store without hiding healthy bindings", () => { + const { root, cleanup } = fixture(); + const bad = join(root, "bad-repo"); + const good = join(root, "good-repo"); + try { + mkdirSync(bad); + mkdirSync(good); + mkdirSync(bindingStorePath(bad), { recursive: true }); + writeBinding(good, "sample-oven", "good.json", BOUND_AT); + const bindings = quietly(() => effectiveBindings({ repoRoots: [bad, good] })); + assert.deepEqual(bindings.get("sample-oven").map((entry) => entry.repoRoot), [good]); + } finally { cleanup(); } +}); diff --git a/src/server/oven-storage.mjs b/src/server/oven-storage.mjs new file mode 100644 index 0000000..b54760f --- /dev/null +++ b/src/server/oven-storage.mjs @@ -0,0 +1,104 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { repoStateDir } from "./repo-state.mjs"; + +export const OVEN_INSTRUCTIONS_MAX_BYTES = 65_536; +export const OVEN_DETAIL_MAX_BYTES = 131_072; +export const OVEN_LINEAGE_MAX_BYTES = OVEN_DETAIL_MAX_BYTES; + +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 optionalLstat(path) { + try { + return lstatSync(path); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +function escapeError(root) { + return new Error(`Custom Oven storage escapes repo state ${repoStateDir(root)}.`); +} + +export function assertCustomOvensDirContained(repoRoot, ovensDir) { + const repo = nearestRealPath(repoRoot); + const realState = nearestRealPath(repoStateDir(repoRoot)); + const realOvens = nearestRealPath(ovensDir); + if (!isWithin(repo, realState) || !isWithin(realState, realOvens)) throw escapeError(repoRoot); + return ovensDir; +} + +export function resolveCustomOvensDir(repoRoot, override, { unsafe = false } = {}) { + const ovensDir = override === undefined ? join(repoStateDir(repoRoot), "ovens") : resolve(repoRoot, override); + assertCustomOvensDir(repoRoot, ovensDir, { unsafe }); + return ovensDir; +} + +export function assertCustomOvensDir(repoRoot, ovensDir, { unsafe = false } = {}) { + const rootEntry = optionalLstat(ovensDir); + if (rootEntry && !rootEntry.isDirectory() && !rootEntry.isSymbolicLink()) { + throw new Error(`Custom Oven storage is not a directory: ${ovensDir}.`); + } + if (!unsafe) assertCustomOvensDirContained(repoRoot, ovensDir); + return ovensDir; +} + +export function assertCustomOvenPath(repoRoot, ovensDir, id, { unsafe = false } = {}) { + const path = join(ovensDir, id); + assertCustomOvensDir(repoRoot, ovensDir, { unsafe }); + const rootEntry = optionalLstat(ovensDir); + const ovenEntry = optionalLstat(path); + if (ovenEntry && !ovenEntry.isDirectory() && !ovenEntry.isSymbolicLink()) { + throw new Error(`Custom Oven ${id} is not a directory: ${path}.`); + } + if (rootEntry && ovenEntry) { + const realRoot = realpathSync(ovensDir); + const realOven = realpathSync(path); + if (!isWithin(realRoot, realOven)) throw new Error(`Custom Oven ${id} escapes ${ovensDir}.`); + } + return path; +} + +export function serializeOvenPackage({ instructions, detail, sidecar } = {}) { + const files = { + "instructions.md": `${instructions}\n`, + "detail.json": `${JSON.stringify(detail, null, 2)}\n`, + ...(sidecar ? { "oven.json": `${JSON.stringify(sidecar, null, 2)}\n` } : {}), + }; + assertOvenPackageFileLimits(files); + return files; +} + +export function assertOvenPackageFileLimits(files) { + const limits = { + "instructions.md": OVEN_INSTRUCTIONS_MAX_BYTES, + "detail.json": OVEN_DETAIL_MAX_BYTES, + "oven.json": OVEN_LINEAGE_MAX_BYTES, + }; + for (const [name, maxBytes] of Object.entries(limits)) { + if (files[name] === undefined) continue; + const bytes = Buffer.byteLength(files[name], "utf8"); + if (bytes > maxBytes) throw new Error(`Oven ${name} is ${bytes} bytes, over the ${maxBytes} byte limit.`); + } +} From e830fc3f29631785b96f15978a4ace13b0ee13fa Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 16:09:15 +0200 Subject: [PATCH 23/30] refactor: split oven CLI and route tests under 400 lines; tag after publish --- .github/workflows/publish.yml | 20 +- scripts/verify-oven-assertions.mjs | 49 ++ scripts/verify-test-files.mjs | 30 ++ scripts/verify.mjs | 88 +--- src/cli/oven-cli.mjs | 201 +------- src/cli/oven-cli.test.mjs | 2 +- src/cli/oven-storage.mjs | 178 +++++++ src/server/dashboard-routes-fixtures.mjs | 249 ++++++++++ src/server/dashboard-routes.test.mjs | 599 +---------------------- src/server/oven-routes.test.mjs | 266 ++++++++++ src/server/run-routes.test.mjs | 86 ++++ 11 files changed, 892 insertions(+), 876 deletions(-) create mode 100644 scripts/verify-oven-assertions.mjs create mode 100644 scripts/verify-test-files.mjs create mode 100644 src/cli/oven-storage.mjs create mode 100644 src/server/dashboard-routes-fixtures.mjs create mode 100644 src/server/oven-routes.test.mjs create mode 100644 src/server/run-routes.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 53c0ff8..57ac31c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,7 +1,7 @@ name: Publish # Manual-only. Bumps the package version (patch/minor/major), gates it, publishes to -# npm, then commits the version bump and pushes a `v` tag back to main. +# npm after committing the version bump, then pushes a `v` tag back to main. # # Trigger from the Actions tab ("Run workflow") or: # gh workflow run publish.yml -f bump=patch @@ -70,10 +70,9 @@ jobs: echo "version=${next}" >> "$GITHUB_OUTPUT" echo "Bumped to ${next}" - # Push the version bump to main BEFORE publishing: if the push fails, main and - # npm stay consistent (no npm release without a recorded commit/tag). The - # reverse order could publish to npm and then fail to record it on main. - - name: Commit, tag, and push + # Record the version bump on main before publishing. A failed publish is then + # rerunnable from the recorded intent, but has no release tag. + - name: Commit and push version bump env: NEXT_VERSION: ${{ steps.bump.outputs.version }} ACTOR: ${{ github.actor }} @@ -84,10 +83,17 @@ jobs: git config user.email "${ACTOR_ID}+${ACTOR}@users.noreply.github.com" git add package.json package-lock.json git commit -m "chore(release): ${NEXT_VERSION}" - git tag "${NEXT_VERSION}" - git push --atomic origin HEAD:main "${NEXT_VERSION}" + git push origin HEAD:main - name: Publish to npm run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Tag published release + env: + NEXT_VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + git tag "${NEXT_VERSION}" + git push --atomic origin "${NEXT_VERSION}" diff --git a/scripts/verify-oven-assertions.mjs b/scripts/verify-oven-assertions.mjs new file mode 100644 index 0000000..68dd12c --- /dev/null +++ b/scripts/verify-oven-assertions.mjs @@ -0,0 +1,49 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { normalizeOvenPackage } from "../src/ovens/oven-contract.mjs"; + +export function assertBuiltInOvenSet(repoRoot, expected) { + const ovensRoot = resolve(repoRoot, "ovens"); + const actual = readdirSync(ovensRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + console.error(`Default Oven ids must be exactly ${wanted.join(", ")}; found ${actual.join(", ") || "none"}.`); + process.exit(1); + } +} + +export function assertSkillSet(repoRoot, expected) { + const skillsRoot = resolve(repoRoot, "skills"); + const actual = readdirSync(skillsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + console.error(`Published skill ids must be exactly ${wanted.join(", ")}; found ${actual.join(", ") || "none"}.`); + process.exit(1); + } +} + +export function assertBuiltInOven(repoRoot, id, expectedName) { + const root = resolve(repoRoot, "ovens", id); + try { + const ovenPackage = normalizeOvenPackage({ + id, + instructions: readFileSync(resolve(root, "instructions.md"), "utf8"), + detail: JSON.parse(readFileSync(resolve(root, "detail.json"), "utf8")), + }); + const heading = ovenPackage.instructions + .split(/\r?\n/u) + .find((line) => /^#\s+\S/u.test(line.trim())) + ?.trim() + .replace(/^#\s+/u, ""); + if (heading !== expectedName) throw new Error(`expected heading "${expectedName}", found "${heading || "none"}"`); + } catch (error) { + console.error(`Default oven ${id} violates the Oven contract: ${error.message}`); + process.exit(1); + } +} diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs new file mode 100644 index 0000000..708d77d --- /dev/null +++ b/scripts/verify-test-files.mjs @@ -0,0 +1,30 @@ +export const verificationTestFiles = [ + "src/server/dashboard-routes.test.mjs", + "src/server/oven-routes.test.mjs", + "src/server/run-routes.test.mjs", + "src/server/dashboard-entry-isolation.test.mjs", + "src/server/burnlist-discovery.test.mjs", + "src/ovens/oven-contract.test.mjs", + "src/ovens/oven-registry.test.mjs", + "src/server/projects.test.mjs", + "src/server/projects-api.test.mjs", + "ovens/differential-testing/engine/differential-testing-adapter-sdk.test.mjs", + "ovens/differential-testing/engine/differential-testing-contract.test.mjs", + "ovens/differential-testing/engine/differential-testing-data-contract.test.mjs", + "ovens/differential-testing/engine/differential-testing-transport-server.test.mjs", + "src/server/discovery.test.mjs", + "src/server/plan-model.test.mjs", + "src/server/fs-safe.test.mjs", + "src/cli/lifecycle-cli.test.mjs", + "src/cli/lifecycle-moves.test.mjs", + "src/cli/registry-cli.test.mjs", + "src/cli/oven-cli.test.mjs", + "src/cli/umbrella.test.mjs", + "src/server/oven-bindings.test.mjs", + "src/server/oven-warm.test.mjs", + "src/server/registry.test.mjs", + "src/server/repo-map.test.mjs", + "src/server/repo-state.test.mjs", + "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", + "dashboard/src/lib/project-open.test.mjs", +]; diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 2b961a5..a598298 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -3,7 +3,8 @@ 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 { normalizeOvenPackage } from "../src/ovens/oven-contract.mjs"; +import { assertBuiltInOven, assertBuiltInOvenSet, assertSkillSet } from "./verify-oven-assertions.mjs"; +import { verificationTestFiles } from "./verify-test-files.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const packageJson = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")); @@ -113,53 +114,6 @@ function assertSourceExcludes(path, needle, message) { } } -function assertBuiltInOvenSet(expected) { - const ovensRoot = resolve(repoRoot, "ovens"); - const actual = readdirSync(ovensRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - const wanted = [...expected].sort(); - if (JSON.stringify(actual) !== JSON.stringify(wanted)) { - console.error(`Default Oven ids must be exactly ${wanted.join(", ")}; found ${actual.join(", ") || "none"}.`); - process.exit(1); - } -} - -function assertSkillSet(expected) { - const skillsRoot = resolve(repoRoot, "skills"); - const actual = readdirSync(skillsRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - const wanted = [...expected].sort(); - if (JSON.stringify(actual) !== JSON.stringify(wanted)) { - console.error(`Published skill ids must be exactly ${wanted.join(", ")}; found ${actual.join(", ") || "none"}.`); - process.exit(1); - } -} - -function assertBuiltInOven(id, expectedName) { - const root = resolve(repoRoot, "ovens", id); - const instructionsPath = resolve(root, "instructions.md"); - const detailPath = resolve(root, "detail.json"); - try { - const ovenPackage = normalizeOvenPackage({ - id, - instructions: readFileSync(instructionsPath, "utf8"), - detail: JSON.parse(readFileSync(detailPath, "utf8")), - }); - const heading = ovenPackage.instructions - .split(/\r?\n/u) - .find((line) => /^#\s+\S/u.test(line.trim())) - ?.trim() - .replace(/^#\s+/u, ""); - if (heading !== expectedName) throw new Error(`expected heading "${expectedName}", found "${heading || "none"}"`); - } catch (error) { - console.error(`Default oven ${id} violates the Oven contract: ${error.message}`); - process.exit(1); - } -} function assertDifferentialTestingContractAssets() { const schemaPath = resolve(repoRoot, "ovens/differential-testing/schema/differential-testing-data.schema.json"); @@ -412,42 +366,14 @@ assertSourceExcludes("README.md", "**Target**", "README still advertises the rem assertSourceExcludes("src/server/burnlist-dashboard-server.mjs", '"/targets"', "Dashboard server still exposes the removed Targets route."); assertSourceExcludes("dashboard/src/App.tsx", '"/targets"', "React dashboard still exposes the removed Targets route."); assertSourceExcludes("src/ovens/oven-contract.mjs", '"target"', "Oven contract still accepts the removed Target widget."); -assertSkillSet(["burnlist"]); -assertBuiltInOvenSet(["checklist", "differential-testing"]); -assertBuiltInOven("checklist", "Checklist"); -assertBuiltInOven("differential-testing", "Differential Testing"); +assertSkillSet(repoRoot, ["burnlist"]); +assertBuiltInOvenSet(repoRoot, ["checklist", "differential-testing"]); +assertBuiltInOven(repoRoot, "checklist", "Checklist"); +assertBuiltInOven(repoRoot, "differential-testing", "Differential Testing"); assertDifferentialTestingContractAssets(); assertPublishablePackage(); -run(process.execPath, [ - "--test", - "src/server/dashboard-routes.test.mjs", - "src/server/dashboard-entry-isolation.test.mjs", - "src/server/burnlist-discovery.test.mjs", - "src/ovens/oven-contract.test.mjs", - "src/ovens/oven-registry.test.mjs", - "src/server/projects.test.mjs", - "src/server/projects-api.test.mjs", - "ovens/differential-testing/engine/differential-testing-adapter-sdk.test.mjs", - "ovens/differential-testing/engine/differential-testing-contract.test.mjs", - "ovens/differential-testing/engine/differential-testing-data-contract.test.mjs", - "ovens/differential-testing/engine/differential-testing-transport-server.test.mjs", - "src/server/discovery.test.mjs", - "src/server/plan-model.test.mjs", - "src/server/fs-safe.test.mjs", - "src/cli/lifecycle-cli.test.mjs", - "src/cli/lifecycle-moves.test.mjs", - "src/cli/registry-cli.test.mjs", - "src/cli/oven-cli.test.mjs", - "src/cli/umbrella.test.mjs", - "src/server/oven-bindings.test.mjs", - "src/server/oven-warm.test.mjs", - "src/server/registry.test.mjs", - "src/server/repo-map.test.mjs", - "src/server/repo-state.test.mjs", - "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", - "dashboard/src/lib/project-open.test.mjs", -]); +run(process.execPath, ["--test", ...verificationTestFiles]); run(process.execPath, ["scripts/register-skills.mjs", "--force-global", "--dry-run"], { env: { ...process.env, HOME: resolve(repoRoot, "fixtures", "npm-home") }, diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index 3167ef7..f356a20 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -7,22 +7,13 @@ // 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 { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; +import { normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; -import { atomicOvenPackage, resolveOvenPackageDir, withOvenPackageLock } from "../server/fs-safe.mjs"; -import { - assertCustomOvensDir, - assertCustomOvenPath, - OVEN_DETAIL_MAX_BYTES, - OVEN_INSTRUCTIONS_MAX_BYTES, - OVEN_LINEAGE_MAX_BYTES, - resolveCustomOvensDir, - serializeOvenPackage, -} from "../server/oven-storage.mjs"; +import { resolveCustomOvensDir } from "../server/oven-storage.mjs"; import { renderGrid, sectionTable } from "./oven-cli-render.mjs"; +import { createOvenCatalog, persistOven, resolvePackageInput } from "./oven-storage.mjs"; import { resolveUmbrella } from "./umbrella.mjs"; // ── argv ──────────────────────────────────────────────────────────────────── @@ -76,117 +67,12 @@ const customOvensDir = resolveCustomOvensDir( { unsafe: unsafeOvensDir }, ); -function safeStat(path) { - try { - return statSync(path); - } catch { - return null; - } -} - -function readTextFileWithLimit(path, maxBytes, label) { - const stat = statSync(path); - if (stat.size > maxBytes) throw new Error(`${label} is ${stat.size} bytes, over the ${maxBytes} byte limit.`); - return readFileSync(path, "utf8"); -} - -function instructionsName(instructions, fallback) { - const heading = instructions.split(/\r?\n/u).find((line) => /^#\s+\S/u.test(line.trim())); - return heading ? heading.trim().replace(/^#\s+/u, "").trim() : fallback; -} - -function instructionsDescription(instructions) { - return ( - instructions - .split(/\r?\n/u) - .map((line) => line.trim()) - .find((line) => line && !line.startsWith("#")) ?? "" - ); -} - -function readOvenDir(root, id, builtIn) { - const safeId = ovenId(id); - let ovenRoot; - try { - const path = builtIn ? join(root, safeId) : assertCustomOvenPath(customRepoRoot, root, safeId, { unsafe: unsafeOvensDir }); - ovenRoot = resolveOvenPackageDir(realpathSync(path)); - } catch (error) { - if (error?.code === "ENOENT") return null; - throw error; - } - try { - const instructionsPath = join(ovenRoot, "instructions.md"); - const detailPath = join(ovenRoot, "detail.json"); - if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; - const ovenPackage = normalizeOvenPackage({ - id: safeId, - instructions: readTextFileWithLimit(instructionsPath, OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"), - detail: JSON.parse(readTextFileWithLimit(detailPath, OVEN_DETAIL_MAX_BYTES, "Oven detail template")), - }); - const lineagePath = join(ovenRoot, "oven.json"); - let forkedFrom; - if (safeStat(lineagePath)?.isFile()) { - try { - forkedFrom = normalizeOvenForkedFrom( - JSON.parse(readTextFileWithLimit(lineagePath, OVEN_LINEAGE_MAX_BYTES, "Oven lineage sidecar")), - ).forkedFrom; - } catch (error) { - throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); - } - } - return { - id: ovenPackage.id, - name: instructionsName(ovenPackage.instructions, safeId), - description: instructionsDescription(ovenPackage.instructions), - builtIn, - path: ovenRoot, - instructions: ovenPackage.instructions, - detail: ovenPackage.detail, - ovenRevision: ovenRevision(ovenPackage), - ...(forkedFrom ? { forkedFrom } : {}), - }; - } catch (error) { - if (error?.code === "ENOENT") return null; - throw error; - } -} - -function ovensIn(root, builtIn) { - if (!builtIn) assertCustomOvensDir(customRepoRoot, root, { unsafe: unsafeOvensDir }); - let entries; - try { - entries = readdirSync(root, { 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 readOvenDir(root, id, builtIn); - } catch (error) { - console.warn(`Ignoring malformed Oven ${id}: ${error.message}`); - return null; - } - }) - .filter(Boolean); -} - -function discoverOvens() { - const byId = new Map(); - for (const oven of ovensIn(builtInOvensDir, true)) byId.set(oven.id, oven); - for (const oven of ovensIn(customOvensDir, false)) if (!byId.get(oven.id)?.builtIn) byId.set(oven.id, oven); - return [...byId.values()].sort( - (left, right) => Number(right.builtIn) - Number(left.builtIn) || left.name.localeCompare(right.name), - ); -} - -function findOven(id) { - const safeId = ovenId(id); - return readOvenDir(builtInOvensDir, safeId, true) ?? readOvenDir(customOvensDir, safeId, false); -} +const { readOvenDir, discoverOvens, findOven } = createOvenCatalog({ + builtInOvensDir, + customOvensDir, + customRepoRoot, + unsafeOvensDir, +}); function printOven(oven) { const cellWidth = Number(flags.get("cell-width") ?? 8); @@ -205,69 +91,6 @@ function printOven(oven) { console.log(sectionTable(oven.detail)); } -// ── input resolution for create/update ─────────────────────────────────────── -function readInput(spec, maxBytes, label) { - if (spec === "-") { - const value = readFileSync(0, "utf8"); - if (Buffer.byteLength(value, "utf8") > maxBytes) throw new Error(`${label} exceeds the ${maxBytes} byte limit.`); - return value; - } - return readTextFileWithLimit(resolve(spec), maxBytes, label); -} - -function resolvePackageInput() { - const pkg = {}; - if (flags.has("package")) { - Object.assign(pkg, JSON.parse(readInput(flags.get("package"), OVEN_DETAIL_MAX_BYTES, "Oven package"))); - } - if (flags.has("dir")) { - const dir = resolve(flags.get("dir")); - const instructionsPath = join(dir, "instructions.md"); - const detailPath = join(dir, "detail.json"); - pkg.instructions = readTextFileWithLimit(instructionsPath, OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); - pkg.detail = JSON.parse(readTextFileWithLimit(detailPath, OVEN_DETAIL_MAX_BYTES, "Oven detail template")); - } - if (flags.has("instructions")) pkg.instructions = readInput(flags.get("instructions"), OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); - if (flags.has("detail")) pkg.detail = JSON.parse(readInput(flags.get("detail"), OVEN_DETAIL_MAX_BYTES, "Oven detail template")); - - const id = ovenId(positionals[0] ?? pkg.id ?? flags.get("id") ?? ""); - 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."); - if (pkg.detail === undefined) throw new Error("Provide a detail skeleton via --detail, --package, or --dir."); - - // Match the dashboard: an explicit name owns the level-one heading; otherwise - // the instructions must already carry one (normalizeOvenPackage enforces it). - let instructions = String(pkg.instructions); - if (name) { - const lines = instructions.split(/\r?\n/u); - const headingIndex = lines.findIndex((line) => /^#\s+\S/u.test(line.trim())); - if (headingIndex === -1) lines.unshift(`# ${name}`, ""); - else lines[headingIndex] = `# ${name}`; - instructions = lines.join("\n"); - } - - const normalized = normalizeOvenPackage({ id, instructions, detail: normalizeOvenDetail(pkg.detail) }); - return normalized; -} - -function persistOven(pkg, { allowReplace, sidecar }) { - const files = serializeOvenPackage({ ...pkg, sidecar }); - try { - assertCustomOvenPath(customRepoRoot, customOvensDir, pkg.id, { unsafe: unsafeOvensDir }); - return withOvenPackageLock(customOvensDir, pkg.id, () => ( - atomicOvenPackage(customOvensDir, pkg.id, files, { - replace: allowReplace, - assertPath: () => assertCustomOvenPath(customRepoRoot, customOvensDir, pkg.id, { unsafe: unsafeOvensDir }), - }) - )); - } catch (error) { - if (!allowReplace && error.message === `${pkg.id} already exists.`) { - throw new Error(`Oven ${pkg.id} already exists. Use \`oven update ${pkg.id}\` or --force.`); - } - throw error; - } -} - function assertCustomTarget(id, verb) { const existing = findOven(id); if (existing?.builtIn) { @@ -391,10 +214,10 @@ try { } if (subcommand === "create" || subcommand === "update") { - const pkg = resolvePackageInput(); + const pkg = resolvePackageInput({ flags, positionals }); assertCustomTarget(pkg.id, subcommand); const allowReplace = subcommand === "update" || flags.has("force"); - const path = persistOven(pkg, { allowReplace }); + const path = persistOven({ customRepoRoot, customOvensDir, unsafeOvensDir }, pkg, { allowReplace }); const saved = readOvenDir(customOvensDir, pkg.id, false); console.log(`${subcommand === "update" ? "Updated" : "Created"} Oven ${pkg.id} at ${path}\n`); printOven(saved); @@ -409,7 +232,7 @@ try { const pkg = normalizeOvenPackage({ id: ovenId(newId), instructions: source.instructions, detail: source.detail }); const sourceRevision = ovenRevision(source); if (findOven(pkg.id)) throw new Error(`Oven ${pkg.id} already exists.`); - const path = persistOven(pkg, { + const path = persistOven({ customRepoRoot, customOvensDir, unsafeOvensDir }, pkg, { allowReplace: false, sidecar: { forkedFrom: { ovenId: source.id, revision: sourceRevision } }, }); diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index da4041d..ef7221b 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -221,7 +221,7 @@ test("oven publication rejects oversized stored bytes without replacing a readab const priorInstructions = readFileSync(join(resolveOvenPackageDir(ovenPath), "instructions.md"), "utf8"); assert.equal(Buffer.byteLength(priorInstructions), 65_536); - writeFileSync(instructionsPath, `# ${"y".repeat(65_534)}`); + writeFileSync(instructionsPath, `# ${"\u0800".repeat(21_844)}xy`); assert.throws( () => run(context, "oven", "update", "sized-oven", "--instructions", instructionsPath, "--detail", detailPath), (error) => String(error.stderr).includes("instructions.md") && String(error.stderr).includes("65536 byte limit"), diff --git a/src/cli/oven-storage.mjs b/src/cli/oven-storage.mjs new file mode 100644 index 0000000..589c067 --- /dev/null +++ b/src/cli/oven-storage.mjs @@ -0,0 +1,178 @@ +import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { normalizeOvenDetail, normalizeOvenForkedFrom, normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; +import { atomicOvenPackage, resolveOvenPackageDir, withOvenPackageLock } from "../server/fs-safe.mjs"; +import { + assertCustomOvensDir, + assertCustomOvenPath, + OVEN_DETAIL_MAX_BYTES, + OVEN_INSTRUCTIONS_MAX_BYTES, + OVEN_LINEAGE_MAX_BYTES, + serializeOvenPackage, +} from "../server/oven-storage.mjs"; + +function safeStat(path) { + try { + return statSync(path); + } catch { + return null; + } +} + +function readTextFileWithLimit(path, maxBytes, label) { + const stat = statSync(path); + if (stat.size > maxBytes) throw new Error(`${label} is ${stat.size} bytes, over the ${maxBytes} byte limit.`); + return readFileSync(path, "utf8"); +} + +function instructionsName(instructions, fallback) { + const heading = instructions.split(/\r?\n/u).find((line) => /^#\s+\S/u.test(line.trim())); + return heading ? heading.trim().replace(/^#\s+/u, "").trim() : fallback; +} + +function instructionsDescription(instructions) { + return instructions + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line && !line.startsWith("#")) ?? ""; +} + +export function createOvenCatalog({ builtInOvensDir, customOvensDir, customRepoRoot, unsafeOvensDir }) { + function readOvenDir(root, id, builtIn) { + const safeId = ovenId(id); + let ovenRoot; + try { + const path = builtIn ? join(root, safeId) : assertCustomOvenPath(customRepoRoot, root, safeId, { unsafe: unsafeOvensDir }); + ovenRoot = resolveOvenPackageDir(realpathSync(path)); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } + try { + const instructionsPath = join(ovenRoot, "instructions.md"); + const detailPath = join(ovenRoot, "detail.json"); + if (!safeStat(instructionsPath)?.isFile() || !safeStat(detailPath)?.isFile()) return null; + const ovenPackage = normalizeOvenPackage({ + id: safeId, + instructions: readTextFileWithLimit(instructionsPath, OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"), + detail: JSON.parse(readTextFileWithLimit(detailPath, OVEN_DETAIL_MAX_BYTES, "Oven detail template")), + }); + const lineagePath = join(ovenRoot, "oven.json"); + let forkedFrom; + if (safeStat(lineagePath)?.isFile()) { + try { + forkedFrom = normalizeOvenForkedFrom( + JSON.parse(readTextFileWithLimit(lineagePath, OVEN_LINEAGE_MAX_BYTES, "Oven lineage sidecar")), + ).forkedFrom; + } catch (error) { + throw new Error(`Oven ${safeId} lineage sidecar is invalid: ${error.message}`); + } + } + return { + id: ovenPackage.id, + name: instructionsName(ovenPackage.instructions, safeId), + description: instructionsDescription(ovenPackage.instructions), + builtIn, + path: ovenRoot, + instructions: ovenPackage.instructions, + detail: ovenPackage.detail, + ovenRevision: ovenRevision(ovenPackage), + ...(forkedFrom ? { forkedFrom } : {}), + }; + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } + } + + function ovensIn(root, builtIn) { + if (!builtIn) assertCustomOvensDir(customRepoRoot, root, { unsafe: unsafeOvensDir }); + let entries; + try { + entries = readdirSync(root, { 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 readOvenDir(root, id, builtIn); + } catch (error) { + console.warn(`Ignoring malformed Oven ${id}: ${error.message}`); + return null; + } + }) + .filter(Boolean); + } + + return { + readOvenDir, + discoverOvens() { + const byId = new Map(); + for (const oven of ovensIn(builtInOvensDir, true)) byId.set(oven.id, oven); + for (const oven of ovensIn(customOvensDir, false)) if (!byId.get(oven.id)?.builtIn) byId.set(oven.id, oven); + return [...byId.values()].sort( + (left, right) => Number(right.builtIn) - Number(left.builtIn) || left.name.localeCompare(right.name), + ); + }, + findOven(id) { + const safeId = ovenId(id); + return readOvenDir(builtInOvensDir, safeId, true) ?? readOvenDir(customOvensDir, safeId, false); + }, + }; +} + +function readInput(spec, maxBytes, label) { + if (spec === "-") { + const value = readFileSync(0, "utf8"); + if (Buffer.byteLength(value, "utf8") > maxBytes) throw new Error(`${label} exceeds the ${maxBytes} byte limit.`); + return value; + } + return readTextFileWithLimit(resolve(spec), maxBytes, label); +} + +export function resolvePackageInput({ flags, positionals }) { + const pkg = {}; + if (flags.has("package")) Object.assign(pkg, JSON.parse(readInput(flags.get("package"), OVEN_DETAIL_MAX_BYTES, "Oven package"))); + if (flags.has("dir")) { + const dir = resolve(flags.get("dir")); + pkg.instructions = readTextFileWithLimit(join(dir, "instructions.md"), OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); + pkg.detail = JSON.parse(readTextFileWithLimit(join(dir, "detail.json"), OVEN_DETAIL_MAX_BYTES, "Oven detail template")); + } + if (flags.has("instructions")) pkg.instructions = readInput(flags.get("instructions"), OVEN_INSTRUCTIONS_MAX_BYTES, "Oven instructions"); + if (flags.has("detail")) pkg.detail = JSON.parse(readInput(flags.get("detail"), OVEN_DETAIL_MAX_BYTES, "Oven detail template")); + + const id = ovenId(positionals[0] ?? pkg.id ?? flags.get("id") ?? ""); + 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."); + if (pkg.detail === undefined) throw new Error("Provide a detail skeleton via --detail, --package, or --dir."); + + let instructions = String(pkg.instructions); + if (name) { + const lines = instructions.split(/\r?\n/u); + const headingIndex = lines.findIndex((line) => /^#\s+\S/u.test(line.trim())); + if (headingIndex === -1) lines.unshift(`# ${name}`, ""); + else lines[headingIndex] = `# ${name}`; + instructions = lines.join("\n"); + } + return normalizeOvenPackage({ id, instructions, detail: normalizeOvenDetail(pkg.detail) }); +} + +export function persistOven({ customRepoRoot, customOvensDir, unsafeOvensDir }, pkg, { allowReplace, sidecar }) { + const files = serializeOvenPackage({ ...pkg, sidecar }); + try { + assertCustomOvenPath(customRepoRoot, customOvensDir, pkg.id, { unsafe: unsafeOvensDir }); + return withOvenPackageLock(customOvensDir, pkg.id, () => atomicOvenPackage(customOvensDir, pkg.id, files, { + replace: allowReplace, + assertPath: () => assertCustomOvenPath(customRepoRoot, customOvensDir, pkg.id, { unsafe: unsafeOvensDir }), + })); + } catch (error) { + if (!allowReplace && error.message === `${pkg.id} already exists.`) { + throw new Error(`Oven ${pkg.id} already exists. Use \`oven update ${pkg.id}\` or --force.`); + } + throw error; + } +} diff --git a/src/server/dashboard-routes-fixtures.mjs b/src/server/dashboard-routes-fixtures.mjs new file mode 100644 index 0000000..ea5ebfe --- /dev/null +++ b/src/server/dashboard-routes-fixtures.mjs @@ -0,0 +1,249 @@ +import { spawn } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { createServer, get, request } from "node:http"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const serverPath = resolve(scriptDirectory, "burnlist-dashboard-server.mjs"); +async function withServer({ + withBurnlist, burnlists, ovenData = [], ovens = [], runs = [], scanRoots, + launchCwd = ".", ovensRoot = ".", setup, +}, callback) { + const fixtureRoot = await mkdtemp(join(tmpdir(), "burnlist-dashboard-routes-")); + const homeRoot = join(fixtureRoot, "home"); + const fixtures = burnlists ?? (withBurnlist ? [{}] : []); + const planPaths = fixtures.map((fixture) => fixturePlanPath(fixtureRoot, fixture)); + const rootPaths = scanRoots ?? (fixtures.length + ? fixtures.map((fixture) => fixture.repoPath ?? "fixture-repo") + : ["fixture-repo"]); + let child = null; + try { + await mkdir(homeRoot, { recursive: true }); + await Promise.all(fixtures.map(async (fixture, index) => { + const planPath = planPaths[index]; + await mkdir(dirname(planPath), { recursive: true }); + await Promise.all([ + writeFile(planPath, burnlistMarkdown(fixture.title ?? "Fixture Burnlist")), + writeFile(join(dirname(planPath), "goal.md"), "# Fixture Goal\n\n## Goal\n\nRoute behavior fixture.\n"), + ]); + })); + const writtenOvenData = await Promise.all(ovenData.map(async (entry, index) => { + const path = join(fixtureRoot, entry.repoPath ?? "", entry.fileName ?? `${entry.id}-${index}.json`); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(entry.payload)); + return { ...entry, path }; + })); + const persistedBindings = new Map(); + for (const entry of writtenOvenData.filter((candidate) => candidate.persisted)) { + const repoRoot = join(fixtureRoot, entry.repoPath); + const bindings = persistedBindings.get(repoRoot) ?? {}; + bindings[entry.id] = { path: relative(repoRoot, entry.path), boundAt: "2026-07-14T12:00:00.000Z" }; + persistedBindings.set(repoRoot, bindings); + } + await Promise.all([...persistedBindings].map(async ([repoRoot, bindings]) => { + const storePath = join(repoRoot, ".local", "burnlist", "bindings.json"); + await mkdir(dirname(storePath), { recursive: true }); + await writeFile(storePath, JSON.stringify({ schemaVersion: 1, bindings })); + })); + if (setup) await setup({ fixtureRoot, homeRoot }); + await Promise.all(ovens.map((oven) => writeOvenFixture(join(fixtureRoot, ovensRoot), oven))); + await Promise.all(runs.map((run) => writeRunFixture(fixtureRoot, run))); + await mkdir(join(fixtureRoot, launchCwd), { recursive: true }); + const port = await availablePort(); + const ovenDataBindings = writtenOvenData + .filter((entry) => entry.override !== false) + .map((entry) => `${entry.id}=${entry.path}`).join(","); + child = spawn(process.execPath, [ + serverPath, + "--port", String(port), + "--auto-port", + "--scan-root", rootPaths.map((path) => join(fixtureRoot, path)).join(","), + "--state-dir", join(fixtureRoot, "state"), + ...(ovenDataBindings ? ["--oven-data", ovenDataBindings] : []), + ], { + cwd: join(fixtureRoot, launchCwd), + env: { ...process.env, HOME: homeRoot }, + stdio: ["ignore", "pipe", "pipe"], + }); + const baseUrl = await waitForServer(child); + // The server canonicalizes scan roots (macOS /var → /private/var), so hand back the realpath'd + // repo root; POST /api/runs requires the repoRoot to match a canonical scan root. + const rawRepoRoot = join(fixtureRoot, rootPaths[0]); + const repoRoot = existsSync(rawRepoRoot) ? realpathSync(rawRepoRoot) : rawRepoRoot; + return await callback({ baseUrl, planPath: planPaths[0], planPaths, repoRoot }); + } finally { + await stopChild(child); + await rm(fixtureRoot, { recursive: true, force: true }); + } +} + +function fixturePlanPath(fixtureRoot, fixture) { + return join( + fixtureRoot, + fixture.repoPath ?? "fixture-repo", + "notes", + "burnlists", + fixture.lifecycle ?? "inprogress", + fixture.id ?? "fixture", + "burnlist.md", + ); +} + +function burnlistMarkdown(title) { + return [ + `# ${title}`, + "", + "## Active Checklist", + "", + "- [ ] ROUTE-01 | Keep root on the list", + " Files/search: dashboard", + " Action: Keep list and detail routes distinct.", + " Done/delete when: The route tests pass.", + " Validate: Run the route characterization tests.", + "", + "## Completed", + "", + ].join("\n"); +} + +function detailFixture() { + return { + version: 1, + columns: 2, + rows: 2, + rowHeight: 48, + cells: [{ + id: "summary", + title: "Summary", + description: "Current status.", + widget: "metric", + source: "/summary", + format: "plain", + column: 1, + row: 1, + columnSpan: 2, + rowSpan: 1, + }], + }; +} + +async function writeOvenFixture(root, fixture) { + const ovenRoot = join(root, ".local", "burnlist", "ovens", fixture.id); + await mkdir(ovenRoot, { recursive: true }); + await Promise.all([ + writeFile(join(ovenRoot, "instructions.md"), fixture.instructions ?? "# Fixture Oven\n\nFollow the checklist.\n"), + writeFile(join(ovenRoot, "detail.json"), JSON.stringify(fixture.detail ?? detailFixture())), + ...(fixture.ovenJson === undefined + ? [] + : [writeFile(join(ovenRoot, "oven.json"), typeof fixture.ovenJson === "string" ? fixture.ovenJson : JSON.stringify(fixture.ovenJson))]), + ]); +} + +async function writeRunFixture(fixtureRoot, fixture) { + const repoRoot = join(fixtureRoot, fixture.repoPath ?? "fixture-repo"); + const runRoot = join(repoRoot, ".local", "burnlist", "runs", fixture.id); + const createdAt = "2026-07-14T12:00:00.000Z"; + const record = { + schemaVersion: fixture.schemaVersion, + id: fixture.id, + ovenId: fixture.ovenId, + repoRoot, + repo: "fixture-repo", + title: "Fixture run", + status: "requested", + createdAt, + updatedAt: createdAt, + inputs: { objective: "Exercise run reads." }, + summary: {}, + sections: [], + ...(fixture.ovenRevision ? { ovenRevision: fixture.ovenRevision } : {}), + }; + // The repo must be discoverable (repoRoot() requires notes/burnlists) for readBurnRun to search it. + await mkdir(join(repoRoot, "notes", "burnlists", "inprogress"), { recursive: true }); + await mkdir(runRoot, { recursive: true }); + await Promise.all([ + writeFile(join(runRoot, "run.json"), JSON.stringify(record)), + writeFile(join(runRoot, "instructions.md"), fixture.instructionsSnapshot ?? fixture.instructions), + writeFile(join(runRoot, "detail.json"), fixture.detailSnapshot ?? JSON.stringify(fixture.detail)), + ]); +} + +function httpGet(baseUrl, path) { + return new Promise((resolveResponse, reject) => { + const request = get(new URL(path, baseUrl), (response) => { + const chunks = []; + response.setEncoding("utf8"); + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolveResponse({ + status: response.statusCode, + body: chunks.join(""), + })); + }); + request.once("error", reject); + }); +} + +function httpRequest(baseUrl, path, { method, headers, body }) { + return new Promise((resolveResponse, reject) => { + const req = request(new URL(path, baseUrl), { method, headers }, (response) => { + const chunks = []; + response.setEncoding("utf8"); + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => resolveResponse({ status: response.statusCode, body: chunks.join("") })); + }); + req.once("error", reject); + req.end(body); + }); +} + +function availablePort() { + return new Promise((resolvePort, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + const port = typeof address === "object" && address ? address.port : null; + probe.close((error) => { + if (error) reject(error); + else if (!port) reject(new Error("Could not reserve a test port.")); + else resolvePort(port); + }); + }); + }); +} + +function waitForServer(child) { + return new Promise((resolveReady, reject) => { + let output = ""; + const timeout = setTimeout(() => reject(new Error(`Dashboard test server did not start: ${output}`)), 8_000); + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + const match = output.match(/http:\/\/127\.0\.0\.1:\d+\//u); + if (!match) return; + clearTimeout(timeout); + resolveReady(match[0]); + }); + child.stderr.on("data", (chunk) => { output += chunk.toString(); }); + child.once("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`Dashboard test server exited with ${code}: ${output}`)); + }); + }); +} + +async function stopChild(child) { + if (!child || child.exitCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolveExit) => { + const timeout = setTimeout(resolveExit, 2_000); + child.once("exit", () => { + clearTimeout(timeout); + resolveExit(); + }); + }); +} + +export { detailFixture, httpGet, httpRequest, withServer }; diff --git a/src/server/dashboard-routes.test.mjs b/src/server/dashboard-routes.test.mjs index 164ff12..3f6d2e1 100644 --- a/src/server/dashboard-routes.test.mjs +++ b/src/server/dashboard-routes.test.mjs @@ -1,21 +1,6 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { createServer, get, request } from "node:http"; -import { existsSync, realpathSync } from "node:fs"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { buildPayload } from "../../ovens/differential-testing/example/adapter.mjs"; -import { normalizeOvenPackage, ovenRevision } from "../ovens/oven-contract.mjs"; import test from "node:test"; - -const scriptDirectory = dirname(fileURLToPath(import.meta.url)); -const serverPath = resolve(scriptDirectory, "burnlist-dashboard-server.mjs"); - -// Upstream serves the built dashboard app for shell routes and exposes read-only JSON APIs -// (/api/progress, /api/burnlists). These tests characterize the JSON data routes our PR-1 -// discovery/selection changes touch (the server no longer renders a no-JS HTML fallback). +import { httpGet, withServer } from "./dashboard-routes-fixtures.mjs"; test("root serves the dashboard shell", { timeout: 20_000 }, async () => { await withServer({ withBurnlist: true }, async ({ baseUrl }) => { @@ -79,585 +64,3 @@ test("/api/burnlists lists discovered Burnlists across the observer set", { time assert.equal(typeof entry.planPath, "string"); }); }); - -test("unreadable binding stores do not affect unrelated routes and healthy Oven data", { timeout: 20_000 }, async () => { - await withServer({ - burnlists: [{ repoPath: "bad" }, { repoPath: "good" }], - ovenData: [{ id: "checklist", payload: { source: "good" }, repoPath: "good", persisted: true, override: false }], - setup: async ({ fixtureRoot }) => { - await mkdir(join(fixtureRoot, "bad", ".local", "burnlist", "bindings.json"), { recursive: true }); - }, - }, async ({ baseUrl }) => { - assert.equal((await httpGet(baseUrl, "/")).status, 200); - assert.equal((await httpGet(baseUrl, "/favicon.svg")).status, 200); - assert.equal((await httpGet(baseUrl, "/api/progress?repo=bad&id=fixture")).status, 200); - const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; - const good = entries.find((entry) => entry.ovenId === "checklist" && entry.repo === "good"); - assert.ok(good); - const data = await httpGet(baseUrl, `/api/oven-data/checklist?repoKey=${good.repoKey}`); - assert.equal(data.status, 200); - assert.deepEqual(JSON.parse(data.body).payload, { source: "good" }); - }); -}); - -test("registered Oven routes and dashboard entries isolate malformed custom Oven packages", { timeout: 20_000 }, async () => { - const timestamp = "2026-01-01T12:00:00.000Z"; - const differentialTestingPayload = buildPayload( - { - captureId: "reference-fixture", generatedAt: timestamp, - fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], - samples: [{ tick: 0, values: { position: 1 } }], - }, - { captureId: "candidate-fixture", generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, - ); - await withServer({ - withBurnlist: true, - ovens: [{ id: "malformed-oven", ovenJson: "{" }], - ovenData: [ - { id: "checklist", payload: { source: "generic" } }, - { id: "differential-testing", payload: differentialTestingPayload }, - ], - }, async ({ baseUrl }) => { - const checklist = await httpGet(baseUrl, "/api/oven-data/checklist"); - assert.equal(checklist.status, 200); - const checklistResponse = JSON.parse(checklist.body); - assert.deepEqual(checklistResponse.payload, { source: "generic" }); - assert.equal(checklistResponse.validated, false); - - const differentialTesting = await httpGet(baseUrl, "/api/oven-data/differential-testing"); - assert.equal(differentialTesting.status, 200); - const differentialTestingResponse = JSON.parse(differentialTesting.body); - assert.equal(differentialTestingResponse.scenarioId, differentialTestingPayload.scenarioCatalog.selectedScenarioId); - assert.equal(Object.hasOwn(differentialTestingResponse, "validated"), false); - - const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; - assert.equal(entries.some((entry) => entry.ovenId === "checklist"), true); - assert.equal(entries.some((entry) => entry.ovenId === "differential-testing"), true); - - const ovens = await httpGet(baseUrl, "/api/ovens"); - assert.equal(ovens.status, 200); - assert.equal(JSON.parse(ovens.body).ovens.some((oven) => oven.id === "checklist"), true); - const malformed = await httpGet(baseUrl, "/api/ovens/malformed-oven"); - assert.equal(malformed.status, 400); - assert.match(JSON.parse(malformed.body).error, /lineage sidecar is invalid/u); - }); -}); - -test("an unknown Oven with a data binding remains unvalidated", { timeout: 20_000 }, async () => { - await withServer({ ovenData: [{ id: "ghost", payload: { ignored: true } }] }, async ({ baseUrl }) => { - const unknown = await httpGet(baseUrl, "/api/oven-data/ghost"); - assert.equal(unknown.status, 404); - assert.equal(JSON.parse(unknown.body).validated, false); - }); -}); - -test("a discovered custom Oven with a data binding is served as unvalidated JSON", { timeout: 20_000 }, async () => { - await withServer({ - ovens: [{ id: "custom-oven" }], - ovenData: [{ id: "custom-oven", payload: { source: "custom" } }], - }, async ({ baseUrl }) => { - const response = await httpGet(baseUrl, "/api/oven-data/custom-oven"); - assert.equal(response.status, 200); - assert.deepEqual(JSON.parse(response.body).payload, { source: "custom" }); - assert.equal(JSON.parse(response.body).validated, false); - }); -}); - -test("Differential Testing bindings remain distinct for each repository", { timeout: 20_000 }, async () => { - const timestamp = "2026-01-01T12:00:00.000Z"; - const payloadFor = (captureId) => buildPayload( - { - captureId, generatedAt: timestamp, - fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], - samples: [{ tick: 0, values: { position: 1 } }], - }, - { captureId: `${captureId}-candidate`, generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, - ); - const first = payloadFor("first-repo"); - const second = payloadFor("second-repo"); - await withServer({ - burnlists: [{ repoPath: "a/first" }, { repoPath: "b/second" }], - scanRoots: ["a", "b"], - ovenData: [ - { id: "differential-testing", payload: first, repoPath: "a/first", persisted: true, override: false }, - { id: "differential-testing", payload: second, repoPath: "b/second", persisted: true, override: false }, - ], - }, async ({ baseUrl }) => { - const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists - .filter((entry) => entry.ovenId === "differential-testing"); - assert.equal(entries.length, 2); - assert.equal(new Set(entries.map((entry) => entry.repoKey)).size, 2); - 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")); - } - - const firstEntry = entries.find((entry) => entry.title === "first-repo"); - const secondEntry = entries.find((entry) => entry.title === "second-repo"); - assert.ok(firstEntry); - assert.ok(secondEntry); - const firstResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${firstEntry.repoKey}`); - const secondResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${secondEntry.repoKey}`); - assert.equal(firstResponse.status, 200); - assert.equal(secondResponse.status, 200); - assert.equal(JSON.parse(firstResponse.body).payload.subtitle, "first-repo / first-repo-candidate"); - assert.equal(JSON.parse(secondResponse.body).payload.subtitle, "second-repo / second-repo-candidate"); - }); -}); - -test("invalid Differential Testing bindings render blocked rows without hiding valid bindings or Checklists", { timeout: 20_000 }, async () => { - const timestamp = "2026-01-01T12:00:00.000Z"; - const valid = buildPayload( - { - captureId: "valid-repo", generatedAt: timestamp, - fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], - samples: [{ tick: 0, values: { position: 1 } }], - }, - { captureId: "valid-repo-candidate", generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, - ); - await withServer({ - burnlists: [{ repoPath: "a/broken" }, { repoPath: "b/valid" }], - scanRoots: ["a", "b"], - ovenData: [ - { id: "differential-testing", payload: {}, repoPath: "a/broken", persisted: true, override: false }, - { id: "differential-testing", payload: valid, repoPath: "b/valid", persisted: true, override: false }, - ], - }, async ({ baseUrl }) => { - const response = await httpGet(baseUrl, "/api/burnlists"); - assert.equal(response.status, 200); - const entries = JSON.parse(response.body).burnlists; - assert.equal(entries.filter((entry) => entry.ovenId === "checklist").length, 2); - const blocked = entries.find((entry) => entry.ovenId === "differential-testing" && entry.statusLabel === "Blocked"); - assert.ok(blocked); - assert.equal(blocked.status, "active"); - assert.equal(typeof blocked.blockers, "string"); - assert.equal(entries.some((entry) => entry.ovenId === "differential-testing" && entry.statusLabel === "Active"), true); - }); -}); - -test("Oven data repo binding falls back to the global override", { timeout: 20_000 }, async () => { - const timestamp = "2026-01-01T12:00:00.000Z"; - const payloadFor = (captureId) => buildPayload( - { - captureId, generatedAt: timestamp, - fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], - samples: [{ tick: 0, values: { position: 1 } }], - }, - { captureId: `${captureId}-candidate`, generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, - ); - const override = payloadFor("global-override"); - const exact = payloadFor("exact-repo"); - await withServer({ - burnlists: [ - { repoPath: "a/first", title: "First repository" }, - { repoPath: "b/second", title: "Second repository" }, - ], - scanRoots: ["a", "b"], - ovenData: [ - { id: "differential-testing", payload: override }, - { id: "differential-testing", payload: exact, repoPath: "a/first", persisted: true, override: false }, - ], - }, async ({ baseUrl }) => { - const burnlists = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; - const dtEntries = burnlists.filter((entry) => entry.ovenId === "differential-testing"); - // repoKeys come from the checklist entries (DT entry titles are scenario labels, not repo titles). - const firstChecklist = burnlists.find((entry) => entry.ovenId === "checklist" && entry.title === "First repository"); - const secondChecklist = burnlists.find((entry) => entry.ovenId === "checklist" && entry.title === "Second repository"); - assert.ok(firstChecklist); - assert.ok(secondChecklist); - // a/first has its own persisted binding → a DT row; b/second (unbound) gets no fabricated row. - assert.equal(dtEntries.some((entry) => entry.repoKey === firstChecklist.repoKey), true); - assert.equal(dtEntries.some((entry) => entry.repoKey === secondChecklist.repoKey), false); - const exactResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${firstChecklist.repoKey}`); - const fallbackResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${secondChecklist.repoKey}`); - assert.equal(exactResponse.status, 200); - assert.equal(fallbackResponse.status, 200); - assert.equal(JSON.parse(exactResponse.body).payload.subtitle, "exact-repo / exact-repo-candidate"); - assert.equal(JSON.parse(fallbackResponse.body).payload.subtitle, "global-override / global-override-candidate"); - }); -}); - -test("Oven discovery exposes optional lineage, skips malformed catalog entries, and keeps direct reads closed", { timeout: 20_000 }, async () => { - const forkedFrom = { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }; - await withServer({ - ovens: [ - { id: "forked-oven", ovenJson: { forkedFrom } }, - { id: "standalone-oven" }, - ], - }, async ({ baseUrl }) => { - const forked = JSON.parse((await httpGet(baseUrl, "/api/ovens/forked-oven")).body).oven; - const standalone = JSON.parse((await httpGet(baseUrl, "/api/ovens/standalone-oven")).body).oven; - assert.deepEqual(forked.forkedFrom, forkedFrom); - assert.equal(Object.hasOwn(standalone, "forkedFrom"), false); - }); - await withServer({ ovens: [{ id: "broken-oven", ovenJson: "{" }] }, async ({ baseUrl }) => { - const response = await httpGet(baseUrl, "/api/ovens"); - assert.equal(response.status, 200); - assert.equal(JSON.parse(response.body).ovens.some((oven) => oven.id === "broken-oven"), false); - const direct = await httpGet(baseUrl, "/api/ovens/broken-oven"); - assert.equal(direct.status, 400); - assert.match(JSON.parse(direct.body).error, /lineage sidecar is invalid/u); - }); -}); - -test("dashboard custom Oven storage follows its umbrella root and rejects symlink escapes", { timeout: 20_000 }, async () => { - await withServer({ - withBurnlist: true, - launchCwd: "fixture-repo/work/nested", - ovensRoot: "fixture-repo", - ovens: [{ id: "umbrella-oven" }], - }, async ({ baseUrl }) => { - const response = await httpGet(baseUrl, "/api/ovens"); - assert.equal(response.status, 200); - assert.equal(JSON.parse(response.body).ovens.some((oven) => oven.id === "umbrella-oven"), true); - }); - - await assert.rejects(() => withServer({ - withBurnlist: true, - launchCwd: "fixture-repo", - setup: async ({ fixtureRoot }) => { - const repo = join(fixtureRoot, "fixture-repo"); - const outside = join(fixtureRoot, "outside"); - await mkdir(outside); - await symlink(outside, join(repo, ".local"), "dir"); - }, - }, async () => assert.fail("server must refuse an escaped custom Oven directory")), /escapes/u); - - await withServer({ - withBurnlist: true, - launchCwd: "fixture-repo", - setup: async ({ fixtureRoot }) => { - const ovens = join(fixtureRoot, "fixture-repo", ".local", "burnlist", "ovens"); - const outside = join(fixtureRoot, "id-outside"); - await mkdir(ovens, { recursive: true }); - await mkdir(outside); - await symlink(outside, join(ovens, "escaped-oven"), "dir"); - }, - }, async ({ baseUrl }) => { - const direct = await httpGet(baseUrl, "/api/ovens/escaped-oven"); - assert.equal(direct.status, 400); - assert.match(JSON.parse(direct.body).error, /escapes/u); - }); -}); - -test("Burn runs read legacy v3 revisions and write/read v4 revisions", { timeout: 20_000 }, async () => { - const legacyInstructions = "# Legacy Oven\n\nFollow the checklist.\n"; - const legacyDetail = detailFixture(); - const expectedLegacyRevision = ovenRevision(normalizeOvenPackage({ - id: "legacy-oven", - instructions: legacyInstructions, - detail: legacyDetail, - })); - const legacyRunId = "20260714-120000-a1b2c3"; - const unsupportedRunId = "20260714-120001-a1b2c4"; - const matchingV4RunId = "20260714-120002-a1b2c5"; - const mismatchedV4RunId = "20260714-120003-a1b2c6"; - await withServer({ - runs: [ - { id: legacyRunId, schemaVersion: 3, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail }, - { id: unsupportedRunId, schemaVersion: 99, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail }, - { id: matchingV4RunId, schemaVersion: 4, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail, ovenRevision: expectedLegacyRevision }, - { id: mismatchedV4RunId, schemaVersion: 4, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail, ovenRevision: `o1-sha256:${"f".repeat(64)}` }, - ], - }, async ({ baseUrl, repoRoot }) => { - const legacy = JSON.parse((await httpGet(baseUrl, `/api/runs/${legacyRunId}`)).body).run; - assert.equal(legacy.schemaVersion, 3); - assert.equal(legacy.ovenRevision, expectedLegacyRevision); - const matchingV4 = await httpGet(baseUrl, `/api/runs/${matchingV4RunId}`); - assert.equal(matchingV4.status, 200); - assert.equal(JSON.parse(matchingV4.body).run.ovenRevision, expectedLegacyRevision); - const mismatchedV4 = await httpGet(baseUrl, `/api/runs/${mismatchedV4RunId}`); - assert.equal(mismatchedV4.status, 400); - assert.match(JSON.parse(mismatchedV4.body).error, /revision does not match its snapshot/u); - const unsupported = await httpGet(baseUrl, `/api/runs/${unsupportedRunId}`); - assert.equal(unsupported.status, 400); - assert.match(JSON.parse(unsupported.body).error, /schemaVersion must be 3 or 4/u); - - const ovens = JSON.parse((await httpGet(baseUrl, "/api/ovens")).body); - const created = await httpRequest(baseUrl, "/api/runs", { - method: "POST", - headers: { - "content-type": "application/json", - "x-burnlist-token": ovens.writeToken, - }, - body: JSON.stringify({ ovenId: "checklist", repoRoot, title: "Current run", objective: "Verify revision pinning." }), - }); - assert.equal(created.status, 201); - const current = JSON.parse(created.body).run; - assert.equal(current.schemaVersion, 4); - assert.match(current.ovenRevision, /^o1-sha256:[a-f0-9]{64}$/u); - const reread = JSON.parse((await httpGet(baseUrl, `/api/runs/${current.id}`)).body).run; - assert.equal(reread.ovenRevision, current.ovenRevision); - }); -}); - -test("Burn runs read max-size normalized v4 Oven snapshots", { timeout: 20_000 }, async () => { - const maxText = (length) => "\u0800".repeat(length); - const instructions = `# ${maxText(65534)}`; - const detail = { - version: 1, columns: 24, rows: 32, rowHeight: 120, - cells: Array.from({ length: 32 }, (_, index) => ({ - id: `section-${String(index).padStart(2, "0")}-${"a".repeat(37)}`, - title: maxText(80), description: maxText(2000), widget: "comparison", - source: `/${maxText(159)}`, format: "timestamp", - column: (index % 24) + 1, row: Math.floor(index / 24) + 1, columnSpan: 1, rowSpan: 1, - })), - }; - const oven = normalizeOvenPackage({ id: "max-size-oven", instructions, detail }); - const instructionsSnapshot = `${oven.instructions}\n`; - const detailSnapshot = `${JSON.stringify(oven.detail, null, 2)}\n`; - assert.ok(Buffer.byteLength(instructionsSnapshot) > 65536); - assert.ok(Buffer.byteLength(detailSnapshot) > 131072); - assert.ok(Buffer.byteLength(instructionsSnapshot) <= 262144); - assert.ok(Buffer.byteLength(detailSnapshot) <= 393216); - const id = "20260714-120004-a1b2c7"; - await withServer({ - runs: [{ - id, schemaVersion: 4, ovenId: oven.id, instructions: oven.instructions, detail: oven.detail, - ovenRevision: ovenRevision(oven), instructionsSnapshot, detailSnapshot, - }], - }, async ({ baseUrl }) => { - const response = await httpGet(baseUrl, `/api/runs/${id}`); - assert.equal(response.status, 200); - assert.equal(JSON.parse(response.body).run.ovenRevision, ovenRevision(oven)); - }); -}); - -async function withServer({ - withBurnlist, burnlists, ovenData = [], ovens = [], runs = [], scanRoots, - launchCwd = ".", ovensRoot = ".", setup, -}, callback) { - const fixtureRoot = await mkdtemp(join(tmpdir(), "burnlist-dashboard-routes-")); - const homeRoot = join(fixtureRoot, "home"); - const fixtures = burnlists ?? (withBurnlist ? [{}] : []); - const planPaths = fixtures.map((fixture) => fixturePlanPath(fixtureRoot, fixture)); - const rootPaths = scanRoots ?? (fixtures.length - ? fixtures.map((fixture) => fixture.repoPath ?? "fixture-repo") - : ["fixture-repo"]); - let child = null; - try { - await mkdir(homeRoot, { recursive: true }); - await Promise.all(fixtures.map(async (fixture, index) => { - const planPath = planPaths[index]; - await mkdir(dirname(planPath), { recursive: true }); - await Promise.all([ - writeFile(planPath, burnlistMarkdown(fixture.title ?? "Fixture Burnlist")), - writeFile(join(dirname(planPath), "goal.md"), "# Fixture Goal\n\n## Goal\n\nRoute behavior fixture.\n"), - ]); - })); - const writtenOvenData = await Promise.all(ovenData.map(async (entry, index) => { - const path = join(fixtureRoot, entry.repoPath ?? "", entry.fileName ?? `${entry.id}-${index}.json`); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(entry.payload)); - return { ...entry, path }; - })); - const persistedBindings = new Map(); - for (const entry of writtenOvenData.filter((candidate) => candidate.persisted)) { - const repoRoot = join(fixtureRoot, entry.repoPath); - const bindings = persistedBindings.get(repoRoot) ?? {}; - bindings[entry.id] = { path: relative(repoRoot, entry.path), boundAt: "2026-07-14T12:00:00.000Z" }; - persistedBindings.set(repoRoot, bindings); - } - await Promise.all([...persistedBindings].map(async ([repoRoot, bindings]) => { - const storePath = join(repoRoot, ".local", "burnlist", "bindings.json"); - await mkdir(dirname(storePath), { recursive: true }); - await writeFile(storePath, JSON.stringify({ schemaVersion: 1, bindings })); - })); - if (setup) await setup({ fixtureRoot, homeRoot }); - await Promise.all(ovens.map((oven) => writeOvenFixture(join(fixtureRoot, ovensRoot), oven))); - await Promise.all(runs.map((run) => writeRunFixture(fixtureRoot, run))); - await mkdir(join(fixtureRoot, launchCwd), { recursive: true }); - const port = await availablePort(); - const ovenDataBindings = writtenOvenData - .filter((entry) => entry.override !== false) - .map((entry) => `${entry.id}=${entry.path}`).join(","); - child = spawn(process.execPath, [ - serverPath, - "--port", String(port), - "--auto-port", - "--scan-root", rootPaths.map((path) => join(fixtureRoot, path)).join(","), - "--state-dir", join(fixtureRoot, "state"), - ...(ovenDataBindings ? ["--oven-data", ovenDataBindings] : []), - ], { - cwd: join(fixtureRoot, launchCwd), - env: { ...process.env, HOME: homeRoot }, - stdio: ["ignore", "pipe", "pipe"], - }); - const baseUrl = await waitForServer(child); - // The server canonicalizes scan roots (macOS /var → /private/var), so hand back the realpath'd - // repo root; POST /api/runs requires the repoRoot to match a canonical scan root. - const rawRepoRoot = join(fixtureRoot, rootPaths[0]); - const repoRoot = existsSync(rawRepoRoot) ? realpathSync(rawRepoRoot) : rawRepoRoot; - return await callback({ baseUrl, planPath: planPaths[0], planPaths, repoRoot }); - } finally { - await stopChild(child); - await rm(fixtureRoot, { recursive: true, force: true }); - } -} - -function fixturePlanPath(fixtureRoot, fixture) { - return join( - fixtureRoot, - fixture.repoPath ?? "fixture-repo", - "notes", - "burnlists", - fixture.lifecycle ?? "inprogress", - fixture.id ?? "fixture", - "burnlist.md", - ); -} - -function burnlistMarkdown(title) { - return [ - `# ${title}`, - "", - "## Active Checklist", - "", - "- [ ] ROUTE-01 | Keep root on the list", - " Files/search: dashboard", - " Action: Keep list and detail routes distinct.", - " Done/delete when: The route tests pass.", - " Validate: Run the route characterization tests.", - "", - "## Completed", - "", - ].join("\n"); -} - -function detailFixture() { - return { - version: 1, - columns: 2, - rows: 2, - rowHeight: 48, - cells: [{ - id: "summary", - title: "Summary", - description: "Current status.", - widget: "metric", - source: "/summary", - format: "plain", - column: 1, - row: 1, - columnSpan: 2, - rowSpan: 1, - }], - }; -} - -async function writeOvenFixture(root, fixture) { - const ovenRoot = join(root, ".local", "burnlist", "ovens", fixture.id); - await mkdir(ovenRoot, { recursive: true }); - await Promise.all([ - writeFile(join(ovenRoot, "instructions.md"), fixture.instructions ?? "# Fixture Oven\n\nFollow the checklist.\n"), - writeFile(join(ovenRoot, "detail.json"), JSON.stringify(fixture.detail ?? detailFixture())), - ...(fixture.ovenJson === undefined - ? [] - : [writeFile(join(ovenRoot, "oven.json"), typeof fixture.ovenJson === "string" ? fixture.ovenJson : JSON.stringify(fixture.ovenJson))]), - ]); -} - -async function writeRunFixture(fixtureRoot, fixture) { - const repoRoot = join(fixtureRoot, fixture.repoPath ?? "fixture-repo"); - const runRoot = join(repoRoot, ".local", "burnlist", "runs", fixture.id); - const createdAt = "2026-07-14T12:00:00.000Z"; - const record = { - schemaVersion: fixture.schemaVersion, - id: fixture.id, - ovenId: fixture.ovenId, - repoRoot, - repo: "fixture-repo", - title: "Fixture run", - status: "requested", - createdAt, - updatedAt: createdAt, - inputs: { objective: "Exercise run reads." }, - summary: {}, - sections: [], - ...(fixture.ovenRevision ? { ovenRevision: fixture.ovenRevision } : {}), - }; - // The repo must be discoverable (repoRoot() requires notes/burnlists) for readBurnRun to search it. - await mkdir(join(repoRoot, "notes", "burnlists", "inprogress"), { recursive: true }); - await mkdir(runRoot, { recursive: true }); - await Promise.all([ - writeFile(join(runRoot, "run.json"), JSON.stringify(record)), - writeFile(join(runRoot, "instructions.md"), fixture.instructionsSnapshot ?? fixture.instructions), - writeFile(join(runRoot, "detail.json"), fixture.detailSnapshot ?? JSON.stringify(fixture.detail)), - ]); -} - -function httpGet(baseUrl, path) { - return new Promise((resolveResponse, reject) => { - const request = get(new URL(path, baseUrl), (response) => { - const chunks = []; - response.setEncoding("utf8"); - response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => resolveResponse({ - status: response.statusCode, - body: chunks.join(""), - })); - }); - request.once("error", reject); - }); -} - -function httpRequest(baseUrl, path, { method, headers, body }) { - return new Promise((resolveResponse, reject) => { - const req = request(new URL(path, baseUrl), { method, headers }, (response) => { - const chunks = []; - response.setEncoding("utf8"); - response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => resolveResponse({ status: response.statusCode, body: chunks.join("") })); - }); - req.once("error", reject); - req.end(body); - }); -} - -function availablePort() { - return new Promise((resolvePort, reject) => { - const probe = createServer(); - probe.once("error", reject); - probe.listen(0, "127.0.0.1", () => { - const address = probe.address(); - const port = typeof address === "object" && address ? address.port : null; - probe.close((error) => { - if (error) reject(error); - else if (!port) reject(new Error("Could not reserve a test port.")); - else resolvePort(port); - }); - }); - }); -} - -function waitForServer(child) { - return new Promise((resolveReady, reject) => { - let output = ""; - const timeout = setTimeout(() => reject(new Error(`Dashboard test server did not start: ${output}`)), 8_000); - child.stdout.on("data", (chunk) => { - output += chunk.toString(); - const match = output.match(/http:\/\/127\.0\.0\.1:\d+\//u); - if (!match) return; - clearTimeout(timeout); - resolveReady(match[0]); - }); - child.stderr.on("data", (chunk) => { output += chunk.toString(); }); - child.once("exit", (code) => { - clearTimeout(timeout); - reject(new Error(`Dashboard test server exited with ${code}: ${output}`)); - }); - }); -} - -async function stopChild(child) { - if (!child || child.exitCode !== null) return; - child.kill("SIGTERM"); - await new Promise((resolveExit) => { - const timeout = setTimeout(resolveExit, 2_000); - child.once("exit", () => { - clearTimeout(timeout); - resolveExit(); - }); - }); -} diff --git a/src/server/oven-routes.test.mjs b/src/server/oven-routes.test.mjs new file mode 100644 index 0000000..d497c97 --- /dev/null +++ b/src/server/oven-routes.test.mjs @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { mkdir, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { buildPayload } from "../../ovens/differential-testing/example/adapter.mjs"; +import test from "node:test"; +import { httpGet, withServer } from "./dashboard-routes-fixtures.mjs"; +test("unreadable binding stores do not affect unrelated routes and healthy Oven data", { timeout: 20_000 }, async () => { + await withServer({ + burnlists: [{ repoPath: "bad" }, { repoPath: "good" }], + ovenData: [{ id: "checklist", payload: { source: "good" }, repoPath: "good", persisted: true, override: false }], + setup: async ({ fixtureRoot }) => { + await mkdir(join(fixtureRoot, "bad", ".local", "burnlist", "bindings.json"), { recursive: true }); + }, + }, async ({ baseUrl }) => { + assert.equal((await httpGet(baseUrl, "/")).status, 200); + assert.equal((await httpGet(baseUrl, "/favicon.svg")).status, 200); + assert.equal((await httpGet(baseUrl, "/api/progress?repo=bad&id=fixture")).status, 200); + const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; + const good = entries.find((entry) => entry.ovenId === "checklist" && entry.repo === "good"); + assert.ok(good); + const data = await httpGet(baseUrl, `/api/oven-data/checklist?repoKey=${good.repoKey}`); + assert.equal(data.status, 200); + assert.deepEqual(JSON.parse(data.body).payload, { source: "good" }); + }); +}); + +test("registered Oven routes and dashboard entries isolate malformed custom Oven packages", { timeout: 20_000 }, async () => { + const timestamp = "2026-01-01T12:00:00.000Z"; + const differentialTestingPayload = buildPayload( + { + captureId: "reference-fixture", generatedAt: timestamp, + fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], + samples: [{ tick: 0, values: { position: 1 } }], + }, + { captureId: "candidate-fixture", generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, + ); + await withServer({ + withBurnlist: true, + ovens: [{ id: "malformed-oven", ovenJson: "{" }], + ovenData: [ + { id: "checklist", payload: { source: "generic" } }, + { id: "differential-testing", payload: differentialTestingPayload }, + ], + }, async ({ baseUrl }) => { + const checklist = await httpGet(baseUrl, "/api/oven-data/checklist"); + assert.equal(checklist.status, 200); + const checklistResponse = JSON.parse(checklist.body); + assert.deepEqual(checklistResponse.payload, { source: "generic" }); + assert.equal(checklistResponse.validated, false); + + const differentialTesting = await httpGet(baseUrl, "/api/oven-data/differential-testing"); + assert.equal(differentialTesting.status, 200); + const differentialTestingResponse = JSON.parse(differentialTesting.body); + assert.equal(differentialTestingResponse.scenarioId, differentialTestingPayload.scenarioCatalog.selectedScenarioId); + assert.equal(Object.hasOwn(differentialTestingResponse, "validated"), false); + + const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; + assert.equal(entries.some((entry) => entry.ovenId === "checklist"), true); + assert.equal(entries.some((entry) => entry.ovenId === "differential-testing"), true); + + const ovens = await httpGet(baseUrl, "/api/ovens"); + assert.equal(ovens.status, 200); + assert.equal(JSON.parse(ovens.body).ovens.some((oven) => oven.id === "checklist"), true); + const malformed = await httpGet(baseUrl, "/api/ovens/malformed-oven"); + assert.equal(malformed.status, 400); + assert.match(JSON.parse(malformed.body).error, /lineage sidecar is invalid/u); + }); +}); + +test("an unknown Oven with a data binding remains unvalidated", { timeout: 20_000 }, async () => { + await withServer({ ovenData: [{ id: "ghost", payload: { ignored: true } }] }, async ({ baseUrl }) => { + const unknown = await httpGet(baseUrl, "/api/oven-data/ghost"); + assert.equal(unknown.status, 404); + assert.equal(JSON.parse(unknown.body).validated, false); + }); +}); + +test("a discovered custom Oven with a data binding is served as unvalidated JSON", { timeout: 20_000 }, async () => { + await withServer({ + ovens: [{ id: "custom-oven" }], + ovenData: [{ id: "custom-oven", payload: { source: "custom" } }], + }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, "/api/oven-data/custom-oven"); + assert.equal(response.status, 200); + assert.deepEqual(JSON.parse(response.body).payload, { source: "custom" }); + assert.equal(JSON.parse(response.body).validated, false); + }); +}); + +test("Differential Testing bindings remain distinct for each repository", { timeout: 20_000 }, async () => { + const timestamp = "2026-01-01T12:00:00.000Z"; + const payloadFor = (captureId) => buildPayload( + { + captureId, generatedAt: timestamp, + fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], + samples: [{ tick: 0, values: { position: 1 } }], + }, + { captureId: `${captureId}-candidate`, generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, + ); + const first = payloadFor("first-repo"); + const second = payloadFor("second-repo"); + await withServer({ + burnlists: [{ repoPath: "a/first" }, { repoPath: "b/second" }], + scanRoots: ["a", "b"], + ovenData: [ + { id: "differential-testing", payload: first, repoPath: "a/first", persisted: true, override: false }, + { id: "differential-testing", payload: second, repoPath: "b/second", persisted: true, override: false }, + ], + }, async ({ baseUrl }) => { + const entries = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists + .filter((entry) => entry.ovenId === "differential-testing"); + assert.equal(entries.length, 2); + assert.equal(new Set(entries.map((entry) => entry.repoKey)).size, 2); + 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")); + } + + const firstEntry = entries.find((entry) => entry.title === "first-repo"); + const secondEntry = entries.find((entry) => entry.title === "second-repo"); + assert.ok(firstEntry); + assert.ok(secondEntry); + const firstResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${firstEntry.repoKey}`); + const secondResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${secondEntry.repoKey}`); + assert.equal(firstResponse.status, 200); + assert.equal(secondResponse.status, 200); + assert.equal(JSON.parse(firstResponse.body).payload.subtitle, "first-repo / first-repo-candidate"); + assert.equal(JSON.parse(secondResponse.body).payload.subtitle, "second-repo / second-repo-candidate"); + }); +}); + +test("invalid Differential Testing bindings render blocked rows without hiding valid bindings or Checklists", { timeout: 20_000 }, async () => { + const timestamp = "2026-01-01T12:00:00.000Z"; + const valid = buildPayload( + { + captureId: "valid-repo", generatedAt: timestamp, + fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], + samples: [{ tick: 0, values: { position: 1 } }], + }, + { captureId: "valid-repo-candidate", generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, + ); + await withServer({ + burnlists: [{ repoPath: "a/broken" }, { repoPath: "b/valid" }], + scanRoots: ["a", "b"], + ovenData: [ + { id: "differential-testing", payload: {}, repoPath: "a/broken", persisted: true, override: false }, + { id: "differential-testing", payload: valid, repoPath: "b/valid", persisted: true, override: false }, + ], + }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, "/api/burnlists"); + assert.equal(response.status, 200); + const entries = JSON.parse(response.body).burnlists; + assert.equal(entries.filter((entry) => entry.ovenId === "checklist").length, 2); + const blocked = entries.find((entry) => entry.ovenId === "differential-testing" && entry.statusLabel === "Blocked"); + assert.ok(blocked); + assert.equal(blocked.status, "active"); + assert.equal(typeof blocked.blockers, "string"); + assert.equal(entries.some((entry) => entry.ovenId === "differential-testing" && entry.statusLabel === "Active"), true); + }); +}); + +test("Oven data repo binding falls back to the global override", { timeout: 20_000 }, async () => { + const timestamp = "2026-01-01T12:00:00.000Z"; + const payloadFor = (captureId) => buildPayload( + { + captureId, generatedAt: timestamp, + fields: [{ id: "position", label: "Position", sourceOwner: "fixture", meaning: "Position", unit: "units", tolerance: 0 }], + samples: [{ tick: 0, values: { position: 1 } }], + }, + { captureId: `${captureId}-candidate`, generatedAt: timestamp, samples: [{ tick: 0, values: { position: 1 } }] }, + ); + const override = payloadFor("global-override"); + const exact = payloadFor("exact-repo"); + await withServer({ + burnlists: [ + { repoPath: "a/first", title: "First repository" }, + { repoPath: "b/second", title: "Second repository" }, + ], + scanRoots: ["a", "b"], + ovenData: [ + { id: "differential-testing", payload: override }, + { id: "differential-testing", payload: exact, repoPath: "a/first", persisted: true, override: false }, + ], + }, async ({ baseUrl }) => { + const burnlists = JSON.parse((await httpGet(baseUrl, "/api/burnlists")).body).burnlists; + const dtEntries = burnlists.filter((entry) => entry.ovenId === "differential-testing"); + // repoKeys come from the checklist entries (DT entry titles are scenario labels, not repo titles). + const firstChecklist = burnlists.find((entry) => entry.ovenId === "checklist" && entry.title === "First repository"); + const secondChecklist = burnlists.find((entry) => entry.ovenId === "checklist" && entry.title === "Second repository"); + assert.ok(firstChecklist); + assert.ok(secondChecklist); + // a/first has its own persisted binding → a DT row; b/second (unbound) gets no fabricated row. + assert.equal(dtEntries.some((entry) => entry.repoKey === firstChecklist.repoKey), true); + assert.equal(dtEntries.some((entry) => entry.repoKey === secondChecklist.repoKey), false); + const exactResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${firstChecklist.repoKey}`); + const fallbackResponse = await httpGet(baseUrl, `/api/oven-data/differential-testing?repoKey=${secondChecklist.repoKey}`); + assert.equal(exactResponse.status, 200); + assert.equal(fallbackResponse.status, 200); + assert.equal(JSON.parse(exactResponse.body).payload.subtitle, "exact-repo / exact-repo-candidate"); + assert.equal(JSON.parse(fallbackResponse.body).payload.subtitle, "global-override / global-override-candidate"); + }); +}); + +test("Oven discovery exposes optional lineage, skips malformed catalog entries, and keeps direct reads closed", { timeout: 20_000 }, async () => { + const forkedFrom = { ovenId: "source-oven", revision: `o1-sha256:${"a".repeat(64)}` }; + await withServer({ + ovens: [ + { id: "forked-oven", ovenJson: { forkedFrom } }, + { id: "standalone-oven" }, + ], + }, async ({ baseUrl }) => { + const forked = JSON.parse((await httpGet(baseUrl, "/api/ovens/forked-oven")).body).oven; + const standalone = JSON.parse((await httpGet(baseUrl, "/api/ovens/standalone-oven")).body).oven; + assert.deepEqual(forked.forkedFrom, forkedFrom); + assert.equal(Object.hasOwn(standalone, "forkedFrom"), false); + }); + await withServer({ ovens: [{ id: "broken-oven", ovenJson: "{" }] }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, "/api/ovens"); + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).ovens.some((oven) => oven.id === "broken-oven"), false); + const direct = await httpGet(baseUrl, "/api/ovens/broken-oven"); + assert.equal(direct.status, 400); + assert.match(JSON.parse(direct.body).error, /lineage sidecar is invalid/u); + }); +}); + +test("dashboard custom Oven storage follows its umbrella root and rejects symlink escapes", { timeout: 20_000 }, async () => { + await withServer({ + withBurnlist: true, + launchCwd: "fixture-repo/work/nested", + ovensRoot: "fixture-repo", + ovens: [{ id: "umbrella-oven" }], + }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, "/api/ovens"); + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).ovens.some((oven) => oven.id === "umbrella-oven"), true); + }); + + await assert.rejects(() => withServer({ + withBurnlist: true, + launchCwd: "fixture-repo", + setup: async ({ fixtureRoot }) => { + const repo = join(fixtureRoot, "fixture-repo"); + const outside = join(fixtureRoot, "outside"); + await mkdir(outside); + await symlink(outside, join(repo, ".local"), "dir"); + }, + }, async () => assert.fail("server must refuse an escaped custom Oven directory")), /escapes/u); + + await withServer({ + withBurnlist: true, + launchCwd: "fixture-repo", + setup: async ({ fixtureRoot }) => { + const ovens = join(fixtureRoot, "fixture-repo", ".local", "burnlist", "ovens"); + const outside = join(fixtureRoot, "id-outside"); + await mkdir(ovens, { recursive: true }); + await mkdir(outside); + await symlink(outside, join(ovens, "escaped-oven"), "dir"); + }, + }, async ({ baseUrl }) => { + const direct = await httpGet(baseUrl, "/api/ovens/escaped-oven"); + assert.equal(direct.status, 400); + assert.match(JSON.parse(direct.body).error, /escapes/u); + }); +}); diff --git a/src/server/run-routes.test.mjs b/src/server/run-routes.test.mjs new file mode 100644 index 0000000..676e061 --- /dev/null +++ b/src/server/run-routes.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { normalizeOvenPackage, ovenRevision } from "../ovens/oven-contract.mjs"; +import test from "node:test"; +import { detailFixture, httpGet, httpRequest, withServer } from "./dashboard-routes-fixtures.mjs"; +test("Burn runs read legacy v3 revisions and write/read v4 revisions", { timeout: 20_000 }, async () => { + const legacyInstructions = "# Legacy Oven\n\nFollow the checklist.\n"; + const legacyDetail = detailFixture(); + const expectedLegacyRevision = ovenRevision(normalizeOvenPackage({ + id: "legacy-oven", + instructions: legacyInstructions, + detail: legacyDetail, + })); + const legacyRunId = "20260714-120000-a1b2c3"; + const unsupportedRunId = "20260714-120001-a1b2c4"; + const matchingV4RunId = "20260714-120002-a1b2c5"; + const mismatchedV4RunId = "20260714-120003-a1b2c6"; + await withServer({ + runs: [ + { id: legacyRunId, schemaVersion: 3, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail }, + { id: unsupportedRunId, schemaVersion: 99, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail }, + { id: matchingV4RunId, schemaVersion: 4, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail, ovenRevision: expectedLegacyRevision }, + { id: mismatchedV4RunId, schemaVersion: 4, ovenId: "legacy-oven", instructions: legacyInstructions, detail: legacyDetail, ovenRevision: `o1-sha256:${"f".repeat(64)}` }, + ], + }, async ({ baseUrl, repoRoot }) => { + const legacy = JSON.parse((await httpGet(baseUrl, `/api/runs/${legacyRunId}`)).body).run; + assert.equal(legacy.schemaVersion, 3); + assert.equal(legacy.ovenRevision, expectedLegacyRevision); + const matchingV4 = await httpGet(baseUrl, `/api/runs/${matchingV4RunId}`); + assert.equal(matchingV4.status, 200); + assert.equal(JSON.parse(matchingV4.body).run.ovenRevision, expectedLegacyRevision); + const mismatchedV4 = await httpGet(baseUrl, `/api/runs/${mismatchedV4RunId}`); + assert.equal(mismatchedV4.status, 400); + assert.match(JSON.parse(mismatchedV4.body).error, /revision does not match its snapshot/u); + const unsupported = await httpGet(baseUrl, `/api/runs/${unsupportedRunId}`); + assert.equal(unsupported.status, 400); + assert.match(JSON.parse(unsupported.body).error, /schemaVersion must be 3 or 4/u); + + const ovens = JSON.parse((await httpGet(baseUrl, "/api/ovens")).body); + const created = await httpRequest(baseUrl, "/api/runs", { + method: "POST", + headers: { + "content-type": "application/json", + "x-burnlist-token": ovens.writeToken, + }, + body: JSON.stringify({ ovenId: "checklist", repoRoot, title: "Current run", objective: "Verify revision pinning." }), + }); + assert.equal(created.status, 201); + const current = JSON.parse(created.body).run; + assert.equal(current.schemaVersion, 4); + assert.match(current.ovenRevision, /^o1-sha256:[a-f0-9]{64}$/u); + const reread = JSON.parse((await httpGet(baseUrl, `/api/runs/${current.id}`)).body).run; + assert.equal(reread.ovenRevision, current.ovenRevision); + }); +}); + +test("Burn runs read max-size normalized v4 Oven snapshots", { timeout: 20_000 }, async () => { + const maxText = (length) => "\u0800".repeat(length); + const instructions = `# ${maxText(65534)}`; + const detail = { + version: 1, columns: 24, rows: 32, rowHeight: 120, + cells: Array.from({ length: 32 }, (_, index) => ({ + id: `section-${String(index).padStart(2, "0")}-${"a".repeat(37)}`, + title: maxText(80), description: maxText(2000), widget: "comparison", + source: `/${maxText(159)}`, format: "timestamp", + column: (index % 24) + 1, row: Math.floor(index / 24) + 1, columnSpan: 1, rowSpan: 1, + })), + }; + const oven = normalizeOvenPackage({ id: "max-size-oven", instructions, detail }); + const instructionsSnapshot = `${oven.instructions}\n`; + const detailSnapshot = `${JSON.stringify(oven.detail, null, 2)}\n`; + assert.ok(Buffer.byteLength(instructionsSnapshot) > 65536); + assert.ok(Buffer.byteLength(detailSnapshot) > 131072); + assert.ok(Buffer.byteLength(instructionsSnapshot) <= 262144); + assert.ok(Buffer.byteLength(detailSnapshot) <= 393216); + const id = "20260714-120004-a1b2c7"; + await withServer({ + runs: [{ + id, schemaVersion: 4, ovenId: oven.id, instructions: oven.instructions, detail: oven.detail, + ovenRevision: ovenRevision(oven), instructionsSnapshot, detailSnapshot, + }], + }, async ({ baseUrl }) => { + const response = await httpGet(baseUrl, `/api/runs/${id}`); + assert.equal(response.status, 200); + assert.equal(JSON.parse(response.body).run.ovenRevision, ovenRevision(oven)); + }); +}); From 7ea3a3f833fa9cacd91f30b733f3e083b153c59c Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 16:22:44 +0200 Subject: [PATCH 24/30] fix: true per-row dashboard isolation, idempotent publish, ovenId validation --- .github/workflows/publish.yml | 45 +++++++++++-- src/cli/oven-cli.test.mjs | 67 ++++++++++--------- src/server/burnlist-discovery.mjs | 6 +- src/server/burnlist-discovery.test.mjs | 27 +++++--- src/server/dashboard-entry-isolation.mjs | 26 +++++-- src/server/dashboard-entry-isolation.test.mjs | 39 ++++++++--- 6 files changed, 145 insertions(+), 65 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 57ac31c..4e0b310 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,19 +62,49 @@ jobs: npm run verify npm run verify:package + - name: Determine release action + id: release-state + run: | + set -euo pipefail + current="$(node -p "require('./package.json').version")" + tag="v${current}" + if ! npm view "burnlist@${current}" version >/dev/null 2>&1; then + action=publish + elif ! git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + action=tag + else + action=bump + fi + echo "current=${current}" >> "$GITHUB_OUTPUT" + echo "action=${action}" >> "$GITHUB_OUTPUT" + echo "Release action: ${action} (${tag})" + - name: Bump version id: bump + if: steps.release-state.outputs.action == 'bump' run: | set -euo pipefail next="$(npm version "${{ inputs.bump }}" --no-git-tag-version)" echo "version=${next}" >> "$GITHUB_OUTPUT" echo "Bumped to ${next}" - # Record the version bump on main before publishing. A failed publish is then - # rerunnable from the recorded intent, but has no release tag. + - name: Set release version + id: release + env: + ACTION: ${{ steps.release-state.outputs.action }} + CURRENT_VERSION: ${{ steps.release-state.outputs.current }} + BUMPED_VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + version="v${CURRENT_VERSION}" + if [[ "$ACTION" == "bump" ]]; then version="$BUMPED_VERSION"; fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + # Record only a new bump. Reruns publish or tag the recorded version instead. - name: Commit and push version bump + if: steps.release-state.outputs.action == 'bump' env: - NEXT_VERSION: ${{ steps.bump.outputs.version }} + NEXT_VERSION: ${{ steps.release.outputs.version }} ACTOR: ${{ github.actor }} ACTOR_ID: ${{ github.actor_id }} run: | @@ -86,14 +116,19 @@ jobs: git push origin HEAD:main - name: Publish to npm + if: steps.release-state.outputs.action != 'tag' run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Tag published release env: - NEXT_VERSION: ${{ steps.bump.outputs.version }} + NEXT_VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail + if git ls-remote --exit-code --tags origin "refs/tags/${NEXT_VERSION}" >/dev/null 2>&1; then + echo "Tag ${NEXT_VERSION} already exists; skipping." + exit 0 + fi git tag "${NEXT_VERSION}" - git push --atomic origin "${NEXT_VERSION}" + git push origin "${NEXT_VERSION}" diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index ef7221b..ef6c381 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -7,7 +7,6 @@ import test from "node:test"; import { ovenRevision } from "../ovens/oven-contract.mjs"; import { resolveOvenPackageDir } from "../server/fs-safe.mjs"; import { assertCustomOvenPath } from "../server/oven-storage.mjs"; - const repoRoot = resolve(new URL("../..", import.meta.url).pathname); const binPath = join(repoRoot, "bin", "burnlist.mjs"); const serverPath = join(repoRoot, "src", "server", "burnlist-dashboard-server.mjs"); @@ -18,7 +17,6 @@ function fixture() { mkdirSync(repo); return { repo, cleanup: () => rmSync(root, { recursive: true, force: true }) }; } - function run(context, ...args) { return execFileSync(process.execPath, [binPath, ...args], { cwd: context.repo, encoding: "utf8" }); } @@ -77,11 +75,12 @@ function pausedUpdate(context, ovensDir, packagePath) { ], { cwd: context.repo, stdio: ["pipe", "pipe", "pipe"] }); } -function waitForBarrier(child) { +function waitForBarrier(child, timeoutMs = 5_000) { return new Promise((resolve, reject) => { - child.stdout.on("data", (chunk) => { if (chunk.toString().includes("staged")) resolve(); }); - child.on("error", reject); - child.on("close", (status) => reject(new Error(`update ended before reaching its publish barrier: ${status}`))); + const timer = setTimeout(() => reject(new Error(`update did not reach its publish barrier within ${timeoutMs}ms`)), timeoutMs); + child.stdout.on("data", (chunk) => { if (chunk.toString().includes("staged")) { clearTimeout(timer); resolve(); } }); + child.on("error", (error) => { clearTimeout(timer); reject(error); }); + child.on("close", (status) => { clearTimeout(timer); reject(new Error(`update ended before reaching its publish barrier: ${status}`)); }); }); } @@ -314,19 +313,21 @@ test("oven view and list read complete packages on both sides of a publish barri instructions: "# Updated Oven\n\nUpdated checklist.", detail: detailFixture(), })); const writer = pausedUpdate(context, ovensDir, packagePath); - const writerStatus = new Promise((resolve) => writer.on("close", resolve)); - await waitForBarrier(writer); - const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); - const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); - assert.match(oldView.instructions, /Initial checklist/u); - assert.equal(oldList.name, "Initial Oven"); - writer.stdin.end("publish\n"); - const status = await writerStatus; - assert.equal(status, 0); - const newView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); - const newList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); - assert.match(newView.instructions, /Updated checklist/u); - assert.equal(newList.name, "Updated Oven"); + try { + const writerStatus = new Promise((resolve) => writer.on("close", resolve)); + await waitForBarrier(writer); + const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); + const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); + assert.match(oldView.instructions, /Initial checklist/u); + assert.equal(oldList.name, "Initial Oven"); + writer.stdin.end("publish\n"); + const status = await writerStatus; + assert.equal(status, 0); + const newView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); + const newList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); + assert.match(newView.instructions, /Updated checklist/u); + assert.equal(newList.name, "Updated Oven"); + } finally { writer.stdin.end(); writer.kill("SIGKILL"); } } finally { context.cleanup(); } }); @@ -339,19 +340,21 @@ test("a killed writer leaves the prior pointer package readable", async () => { run(context, "oven", "create", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); writeFileSync(packagePath, JSON.stringify({ instructions: "# Interrupted Oven\n\nNever published.", detail: detailFixture() })); const writer = pausedUpdate(context, ovensDir, packagePath); - await waitForBarrier(writer); - const priorCurrent = readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"); - writer.kill("SIGKILL"); - await new Promise((resolve) => writer.on("close", resolve)); - const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); - const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); - assert.match(oldView.instructions, /Initial checklist/u); - assert.equal(oldList.name, "Initial Oven"); - assert.equal(readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"), priorCurrent); - writeFileSync(packagePath, JSON.stringify({ instructions: "# Recovered Oven\n\nPublished after recovery.", detail: detailFixture() })); - run(context, "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); - assert.match(JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)).instructions, /Published after recovery/u); - assert.notEqual(readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"), priorCurrent); + try { + await waitForBarrier(writer); + const priorCurrent = readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"); + writer.kill("SIGKILL"); + await new Promise((resolve) => writer.on("close", resolve)); + const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); + const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); + assert.match(oldView.instructions, /Initial checklist/u); + assert.equal(oldList.name, "Initial Oven"); + assert.equal(readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"), priorCurrent); + writeFileSync(packagePath, JSON.stringify({ instructions: "# Recovered Oven\n\nPublished after recovery.", detail: detailFixture() })); + run(context, "oven", "update", "sample-oven", "--package", packagePath, "--ovens-dir", ovensDir); + assert.match(JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)).instructions, /Published after recovery/u); + assert.notEqual(readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"), priorCurrent); + } finally { writer.stdin.end(); writer.kill("SIGKILL"); } } finally { context.cleanup(); } }); diff --git a/src/server/burnlist-discovery.mjs b/src/server/burnlist-discovery.mjs index e1e37be..63044c6 100644 --- a/src/server/burnlist-discovery.mjs +++ b/src/server/burnlist-discovery.mjs @@ -5,7 +5,9 @@ import { LIFECYCLES, summaryForPlan } from "./plan-model.mjs"; // Discovery is deliberately best-effort: a broken lifecycle directory or plan // must not make every other repository disappear from the observer dashboard. -export function discoverBurnlistSummaries({ repoRoots, maxPlanBytes, lifecycles = LIFECYCLES } = {}) { +export function discoverBurnlistSummaries({ + repoRoots, maxPlanBytes, lifecycles = LIFECYCLES, summaryForPlan: summarize = summaryForPlan, +} = {}) { const entries = []; for (const repoRoot of repoRoots ?? []) { for (const lifecycle of lifecycles) { @@ -22,7 +24,7 @@ export function discoverBurnlistSummaries({ repoRoots, maxPlanBytes, lifecycles const planPath = join(lifecycleRoot, id, "burnlist.md"); if (!safeStat(planPath)?.isFile()) continue; try { - entries.push(summaryForPlan(planPath, maxPlanBytes)); + entries.push(summarize(planPath, maxPlanBytes)); } catch { // summaryForPlan normally turns malformed plans into blocked rows. This // final guard also keeps an unexpected per-plan filesystem failure local. diff --git a/src/server/burnlist-discovery.test.mjs b/src/server/burnlist-discovery.test.mjs index daf2385..fc07520 100644 --- a/src/server/burnlist-discovery.test.mjs +++ b/src/server/burnlist-discovery.test.mjs @@ -9,17 +9,28 @@ function validPlan(title) { return `# ${title}\n\n## Active Checklist\n\n- [ ] ISO-01 | Keep discovery isolated\n\n## Completed\n`; } -test("Burnlist discovery keeps healthy plans when another plan is malformed", () => { +test("Burnlist discovery keeps later healthy plans when one summary throws", () => { const root = mkdtempSync(join(tmpdir(), "burnlist-discovery-")); try { const lifecycle = join(root, "notes", "burnlists", "inprogress"); - mkdirSync(join(lifecycle, "healthy"), { recursive: true }); - mkdirSync(join(lifecycle, "broken"), { recursive: true }); - writeFileSync(join(lifecycle, "healthy", "burnlist.md"), validPlan("Healthy")); - writeFileSync(join(lifecycle, "broken", "burnlist.md"), "\0"); - const summaries = discoverBurnlistSummaries({ repoRoots: [root], maxPlanBytes: 1024 }); - assert.equal(summaries.some((entry) => entry.id === "healthy"), true); - assert.ok((summaries.find((entry) => entry.id === "broken")?.errors ?? 0) > 0); + mkdirSync(join(lifecycle, "a-broken"), { recursive: true }); + mkdirSync(join(lifecycle, "z-healthy"), { recursive: true }); + writeFileSync(join(lifecycle, "a-broken", "burnlist.md"), validPlan("Broken")); + writeFileSync(join(lifecycle, "z-healthy", "burnlist.md"), validPlan("Healthy")); + let forcedFailure = false; + const summaries = discoverBurnlistSummaries({ + repoRoots: [root], + maxPlanBytes: 1024, + summaryForPlan: (planPath) => { + if (planPath.includes("a-broken")) { + forcedFailure = true; + throw new Error("forced summary failure"); + } + return { id: "z-healthy", planPath }; + }, + }); + assert.equal(forcedFailure, true); + assert.deepEqual(summaries.map((entry) => entry.id), ["z-healthy"]); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/server/dashboard-entry-isolation.mjs b/src/server/dashboard-entry-isolation.mjs index 77d2d6a..98a24cb 100644 --- a/src/server/dashboard-entry-isolation.mjs +++ b/src/server/dashboard-entry-isolation.mjs @@ -1,3 +1,5 @@ +import { ovenId } from "../ovens/oven-contract.mjs"; + function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -49,7 +51,7 @@ function dashboardRow(row, repoKeyForRoot) { warnings: count(row.warnings, "warnings"), lastCompletedAt: nullableText(row.lastCompletedAt, "lastCompletedAt"), updatedAt: nullableText(row.updatedAt, "updatedAt"), - ovenId: requiredText(row.ovenId, "ovenId"), + ovenId: ovenId(row.ovenId), ovenName: requiredText(row.ovenName, "ovenName"), href: requiredText(row.href, "href"), progressLabel: requiredText(row.progressLabel, "progressLabel"), @@ -57,20 +59,30 @@ function dashboardRow(row, repoKeyForRoot) { }; } -// A handler's call, row normalization, repo key derivation, and sorting share one -// boundary: no malformed adapter result can poison another Oven's dashboard rows. +// A handler's call, row normalization, repo key derivation, and sorting share +// isolation boundaries: no malformed adapter result can poison other rows or Ovens. export function isolatedDashboardEntries({ handlers, contextForHandler, repoKeyForRoot, blockedEntry }) { const entries = []; for (const handler of handlers) { + let rows; try { - const rows = handler.dashboardEntries?.(contextForHandler(handler)) ?? []; + rows = handler.dashboardEntries?.(contextForHandler(handler)) ?? []; if (!Array.isArray(rows)) throw new Error("Dashboard handler must return an array of rows."); - entries.push(...rows.map((row) => dashboardRow(row, repoKeyForRoot)).sort( - (left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")), - )); } catch (error) { entries.push(blockedEntry(handler, error)); + continue; + } + const normalizedRows = []; + for (const [index, row] of rows.entries()) { + try { + normalizedRows.push(dashboardRow(row, repoKeyForRoot)); + } catch (error) { + normalizedRows.push({ ...blockedEntry(handler, error), id: `blocked-${handler.id}-${index}` }); + } } + entries.push(...normalizedRows.sort( + (left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")), + )); } return entries.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))); } diff --git a/src/server/dashboard-entry-isolation.test.mjs b/src/server/dashboard-entry-isolation.test.mjs index 79caba6..eec381d 100644 --- a/src/server/dashboard-entry-isolation.test.mjs +++ b/src/server/dashboard-entry-isolation.test.mjs @@ -11,22 +11,39 @@ function validRow(id) { }; } -test("dashboard row normalization blocks malformed handler rows without hiding healthy handlers", () => { +function blockedEntry(handler, error) { + return { ...validRow(`blocked-${handler.id}`), ovenId: handler.id, blockers: error.message }; +} + +test("dashboard row normalization isolates malformed rows without hiding their healthy siblings", () => { const handlers = [ - { id: "null-row", dashboardEntries: () => [null] }, - { id: "missing-field", dashboardEntries: () => [{ id: "missing-field" }] }, - { id: "healthy", dashboardEntries: () => [validRow("healthy")] }, + { + id: "mixed", dashboardEntries: () => [ + { ...validRow("older"), updatedAt: "2026-01-01T00:00:00Z" }, + null, + { ...validRow("newer"), updatedAt: "2026-01-03T00:00:00Z" }, + ], + }, + { id: "throws", dashboardEntries: () => { throw new Error("handler failed"); } }, ]; const entries = isolatedDashboardEntries({ handlers, contextForHandler: () => ({}), repoKeyForRoot: (root) => root, - blockedEntry: (handler, error) => ({ ...validRow(`blocked-${handler.id}`), ovenId: handler.id, blockers: error.message }), + blockedEntry, + }); + assert.deepEqual(entries.map((entry) => entry.id), ["newer", "older", "blocked-mixed-1", "blocked-throws"]); + assert.equal(entries.find((entry) => entry.id === "blocked-mixed-1")?.blockers, "Dashboard handler returned a malformed row."); + assert.equal(entries.find((entry) => entry.id === "blocked-throws")?.blockers, "handler failed"); +}); + +test("dashboard row normalization blocks a non-slug oven id", () => { + const entries = isolatedDashboardEntries({ + handlers: [{ id: "contract", dashboardEntries: () => [{ ...validRow("valid"), ovenId: "Not a slug" }] }], + contextForHandler: () => ({}), + repoKeyForRoot: (root) => root, + blockedEntry, }); - assert.equal(entries.some((entry) => entry.id === "healthy"), true); - assert.deepEqual(entries.filter((entry) => entry.id.startsWith("blocked-")).map((entry) => entry.ovenId).sort(), [ - "missing-field", "null-row", - ]); - assert.deepEqual(entries.find((entry) => entry.id === "healthy")?.planPath, null); - assert.deepEqual(entries.find((entry) => entry.id === "healthy")?.planLabel, null); + assert.deepEqual(entries.map((entry) => entry.id), ["blocked-contract-0"]); + assert.match(entries[0].blockers, /Oven id must be a lowercase slug/u); }); From 52354ba7f9b6cd1640d11726b4c79db2f060d047 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 17:21:58 +0200 Subject: [PATCH 25/30] fix: refuse to overwrite a malformed binding store; harden blocked-row ids and tests --- .github/workflows/publish.yml | 1 + src/cli/oven-cli.test.mjs | 8 +++--- src/server/dashboard-entry-isolation.mjs | 20 ++++++++++++--- src/server/dashboard-entry-isolation.test.mjs | 19 +++++++++++--- src/server/oven-bindings.mjs | 25 ++++++++++++++----- src/server/oven-bindings.test.mjs | 19 +++++++++++++- 6 files changed, 73 insertions(+), 19 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4e0b310..d50f1eb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,7 @@ name: Publish # Trigger from the Actions tab ("Run workflow") or: # gh workflow run publish.yml -f bump=patch # Requires an `NPM_TOKEN` repository secret with publish rights. +# Recovery is version-bound: after a failed publish, a maintainer must verify HEAD is still the release bump commit before rerunning. on: workflow_dispatch: inputs: diff --git a/src/cli/oven-cli.test.mjs b/src/cli/oven-cli.test.mjs index ef6c381..e19f5c9 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -20,11 +20,9 @@ function fixture() { function run(context, ...args) { return execFileSync(process.execPath, [binPath, ...args], { cwd: context.repo, encoding: "utf8" }); } - function runFrom(cwd, ...args) { return execFileSync(process.execPath, [binPath, ...args], { cwd, encoding: "utf8" }); } - function detailFixture() { return { version: 1, @@ -84,6 +82,8 @@ function waitForBarrier(child, timeoutMs = 5_000) { }); } +const childClose = (child, timeoutMs = 2_000) => new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`update did not close within ${timeoutMs}ms`)), timeoutMs); child.on("close", (status) => { clearTimeout(timer); resolve(status); }); }); + test("oven bind, bindings, and unbind persist a logical repo-local binding", () => { const context = fixture(); const logicalPath = "../generated/current.json"; @@ -314,7 +314,7 @@ test("oven view and list read complete packages on both sides of a publish barri })); const writer = pausedUpdate(context, ovensDir, packagePath); try { - const writerStatus = new Promise((resolve) => writer.on("close", resolve)); + const writerStatus = childClose(writer); await waitForBarrier(writer); const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); @@ -344,7 +344,7 @@ test("a killed writer leaves the prior pointer package readable", async () => { await waitForBarrier(writer); const priorCurrent = readFileSync(join(ovensDir, "sample-oven", "current"), "utf8"); writer.kill("SIGKILL"); - await new Promise((resolve) => writer.on("close", resolve)); + await childClose(writer); const oldView = JSON.parse(run(context, "oven", "view", "sample-oven", "--json", "--ovens-dir", ovensDir)); const oldList = JSON.parse(run(context, "oven", "list", "--json", "--ovens-dir", ovensDir)).find((oven) => oven.id === "sample-oven"); assert.match(oldView.instructions, /Initial checklist/u); diff --git a/src/server/dashboard-entry-isolation.mjs b/src/server/dashboard-entry-isolation.mjs index 98a24cb..941b2ac 100644 --- a/src/server/dashboard-entry-isolation.mjs +++ b/src/server/dashboard-entry-isolation.mjs @@ -1,5 +1,7 @@ import { ovenId } from "../ovens/oven-contract.mjs"; +const blockedRowPrefix = "\u0000blocked:"; + function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -9,6 +11,12 @@ function requiredText(value, field) { return value; } +function dashboardRowId(value) { + const id = requiredText(value, "id"); + if (id.startsWith(blockedRowPrefix)) throw new Error("Dashboard row id uses a reserved blocked-row prefix."); + return id; +} + function nullableText(value, field) { if (value == null) return null; if (typeof value !== "string") throw new Error(`Dashboard row ${field} must be a string or null.`); @@ -34,7 +42,7 @@ function dashboardRow(row, repoKeyForRoot) { if (repoRoot) key = repoKeyForRoot(repoRoot); return { ...row, - id: requiredText(row.id, "id"), + id: dashboardRowId(row.id), repo: requiredText(row.repo, "repo"), repoKey: key, repoRoot, @@ -59,17 +67,21 @@ function dashboardRow(row, repoKeyForRoot) { }; } +function blockedRow(blockedEntry, handler, error, handlerIndex, rowIndex) { + return { ...blockedEntry(handler, error), id: `${blockedRowPrefix}${handlerIndex}:${rowIndex}` }; +} + // A handler's call, row normalization, repo key derivation, and sorting share // isolation boundaries: no malformed adapter result can poison other rows or Ovens. export function isolatedDashboardEntries({ handlers, contextForHandler, repoKeyForRoot, blockedEntry }) { const entries = []; - for (const handler of handlers) { + for (const [handlerIndex, handler] of handlers.entries()) { let rows; try { rows = handler.dashboardEntries?.(contextForHandler(handler)) ?? []; if (!Array.isArray(rows)) throw new Error("Dashboard handler must return an array of rows."); } catch (error) { - entries.push(blockedEntry(handler, error)); + entries.push(blockedRow(blockedEntry, handler, error, handlerIndex, -1)); continue; } const normalizedRows = []; @@ -77,7 +89,7 @@ export function isolatedDashboardEntries({ handlers, contextForHandler, repoKeyF try { normalizedRows.push(dashboardRow(row, repoKeyForRoot)); } catch (error) { - normalizedRows.push({ ...blockedEntry(handler, error), id: `blocked-${handler.id}-${index}` }); + normalizedRows.push(blockedRow(blockedEntry, handler, error, handlerIndex, index)); } } entries.push(...normalizedRows.sort( diff --git a/src/server/dashboard-entry-isolation.test.mjs b/src/server/dashboard-entry-isolation.test.mjs index eec381d..a7547c2 100644 --- a/src/server/dashboard-entry-isolation.test.mjs +++ b/src/server/dashboard-entry-isolation.test.mjs @@ -32,9 +32,9 @@ test("dashboard row normalization isolates malformed rows without hiding their h repoKeyForRoot: (root) => root, blockedEntry, }); - assert.deepEqual(entries.map((entry) => entry.id), ["newer", "older", "blocked-mixed-1", "blocked-throws"]); - assert.equal(entries.find((entry) => entry.id === "blocked-mixed-1")?.blockers, "Dashboard handler returned a malformed row."); - assert.equal(entries.find((entry) => entry.id === "blocked-throws")?.blockers, "handler failed"); + assert.deepEqual(entries.map((entry) => entry.id), ["newer", "older", "\u0000blocked:0:1", "\u0000blocked:1:-1"]); + assert.equal(entries.find((entry) => entry.id === "\u0000blocked:0:1")?.blockers, "Dashboard handler returned a malformed row."); + assert.equal(entries.find((entry) => entry.id === "\u0000blocked:1:-1")?.blockers, "handler failed"); }); test("dashboard row normalization blocks a non-slug oven id", () => { @@ -44,6 +44,17 @@ test("dashboard row normalization blocks a non-slug oven id", () => { repoKeyForRoot: (root) => root, blockedEntry, }); - assert.deepEqual(entries.map((entry) => entry.id), ["blocked-contract-0"]); + assert.deepEqual(entries.map((entry) => entry.id), ["\u0000blocked:0:0"]); assert.match(entries[0].blockers, /Oven id must be a lowercase slug/u); }); + +test("blocked dashboard row ids cannot collide with valid handler row ids", () => { + const entries = isolatedDashboardEntries({ + handlers: [{ id: "mixed", dashboardEntries: () => [validRow("blocked-mixed-1"), null] }], + contextForHandler: () => ({}), + repoKeyForRoot: (root) => root, + blockedEntry, + }); + assert.deepEqual(entries.map((entry) => entry.id), ["blocked-mixed-1", "\u0000blocked:0:1"]); + assert.equal(new Set(entries.map((entry) => entry.id)).size, entries.length); +}); diff --git a/src/server/oven-bindings.mjs b/src/server/oven-bindings.mjs index 371f968..6372474 100644 --- a/src/server/oven-bindings.mjs +++ b/src/server/oven-bindings.mjs @@ -51,21 +51,34 @@ function corruptStore(path) { return emptyStore(); } -export function readBindingStore(repoRoot) { +function storedBindingStore(repoRoot) { const path = bindingStorePath(repoRoot); let text; try { text = readFileSync(path, "utf8"); } catch (error) { - if (error?.code === "ENOENT") return emptyStore(); + if (error?.code === "ENOENT") return { state: "missing", store: emptyStore() }; throw error; } try { const store = JSON.parse(text); - return validateStore(store) ? store : corruptStore(path); + return validateStore(store) ? { state: "valid", store } : { state: "malformed", store: null }; } catch { - return corruptStore(path); + return { state: "malformed", store: null }; + } +} + +export function readBindingStore(repoRoot) { + const result = storedBindingStore(repoRoot); + return result.state === "malformed" ? corruptStore(bindingStorePath(repoRoot)) : result.store; +} + +function mutableBindingStore(repoRoot) { + const result = storedBindingStore(repoRoot); + if (result.state === "malformed") { + throw new Error(`Refusing to modify malformed Oven binding store: ${bindingStorePath(repoRoot)}`); } + return result.store; } function writeStore(repoRoot, store) { @@ -87,7 +100,7 @@ export function writeBinding(repoRoot, id, logicalPath, boundAt) { 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 = readBindingStore(repoRoot); + const store = mutableBindingStore(repoRoot); store.bindings[safeId] = { path: logicalPath, boundAt }; writeStore(repoRoot, store); return { path: bindingStorePath(repoRoot), binding: store.bindings[safeId] }; @@ -97,7 +110,7 @@ export function writeBinding(repoRoot, id, logicalPath, boundAt) { export function removeBinding(repoRoot, id) { const safeId = ovenId(id); return withRepoStateLock(repoRoot, () => { - const store = readBindingStore(repoRoot); + const store = mutableBindingStore(repoRoot); if (!Object.hasOwn(store.bindings, safeId)) return false; delete store.bindings[safeId]; writeStore(repoRoot, store); diff --git a/src/server/oven-bindings.test.mjs b/src/server/oven-bindings.test.mjs index a4b182d..4882b8f 100644 --- a/src/server/oven-bindings.test.mjs +++ b/src/server/oven-bindings.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; @@ -54,6 +54,23 @@ test("missing, corrupt, and obsolete binding stores fail closed", () => { } finally { cleanup(); } }); +test("binding mutations preserve malformed stores and create or update valid stores", () => { + const { root, cleanup } = fixture(); + const path = bindingStorePath(root); + try { + const missing = writeBinding(root, "sample-oven", "created.json", BOUND_AT); + assert.deepEqual(missing.binding, { path: "created.json", boundAt: BOUND_AT }); + writeBinding(root, "sample-oven", "updated.json", "2026-07-14T12:01:00.000Z"); + assert.deepEqual(readBindingStore(root).bindings["sample-oven"], { path: "updated.json", boundAt: "2026-07-14T12:01:00.000Z" }); + const malformed = '{"schemaVersion":1,"bindings":{"other-oven":'; + writeFileSync(path, malformed); + assert.throws(() => writeBinding(root, "sample-oven", "lost.json", BOUND_AT), /Refusing to modify malformed Oven binding store/u); + assert.equal(readFileSync(path, "utf8"), malformed); + assert.throws(() => removeBinding(root, "sample-oven"), /Refusing to modify malformed Oven binding store/u); + assert.equal(readFileSync(path, "utf8"), malformed); + } finally { cleanup(); } +}); + test("effective bindings re-read an atomically replaced store with an identical mtime", () => { const { root, cleanup } = fixture(); try { From dbe2378d94dc90b0aeeb32656fecd2cb89a8c87b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 14 Jul 2026 18:40:06 +0200 Subject: [PATCH 26/30] feat: identify custom ovens per repo (repoKey) with global built-ins --- README.md | 2 +- bin/burnlist.mjs | 2 +- .../src/components/BurnOvens/BurnOvens.tsx | 46 ++++--- dashboard/src/lib/oven-identity.mjs | 13 ++ dashboard/src/lib/oven-identity.test.mjs | 13 ++ dashboard/src/lib/types.ts | 10 ++ scripts/verify-test-files.mjs | 1 + src/server/burnlist-dashboard-server.mjs | 112 +++++++++++++----- src/server/dashboard-routes-fixtures.mjs | 5 +- src/server/oven-routes.test.mjs | 104 +++++++++++++++- 10 files changed, 253 insertions(+), 55 deletions(-) create mode 100644 dashboard/src/lib/oven-identity.mjs create mode 100644 dashboard/src/lib/oven-identity.test.mjs diff --git a/README.md b/README.md index a2320d2..ed3336c 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Burnlist ships with two default Ovens: - **Checklist** tracks the active work queue. - **Differential Testing** renders aligned reference and candidate series, optional aggregate telemetry, and optional exact-first evidence. -Custom Ovens use the same two-file package. An Oven cannot run commands, collect or transform project data, mutate project files, import arbitrary UI, or start an agent. See the [Oven contract](skills/burnlist/references/oven-contract.md) for the complete boundary. +Custom Ovens use the same two-file package and are scoped to the repository that owns their ignored local state; built-in Ovens are global. An Oven cannot run commands, collect or transform project data, mutate project files, import arbitrary UI, or start an agent. `--ovens-dir` overrides custom Oven storage only for the dashboard's launch repository, while other observed repositories continue to use their own `.local/burnlist/ovens/`. See the [Oven contract](skills/burnlist/references/oven-contract.md) for the complete boundary. ## Differential Testing diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 66910e4..1cbfc88 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -137,7 +137,7 @@ Options: --auto-port Try the next available loopback port. --host Bind host; loopback is required by default. --state-dir Override ignored dashboard observer state. - --ovens-dir Override custom Oven storage. + --ovens-dir Override launch-repository custom Oven storage only. --runs-dir Override Run snapshot storage. --oven-data Bind one Oven to a read-only normalized JSON payload. --version, -v Print the installed Burnlist version. diff --git a/dashboard/src/components/BurnOvens/BurnOvens.tsx b/dashboard/src/components/BurnOvens/BurnOvens.tsx index fee7510..4264a89 100644 --- a/dashboard/src/components/BurnOvens/BurnOvens.tsx +++ b/dashboard/src/components/BurnOvens/BurnOvens.tsx @@ -25,6 +25,8 @@ import { X, } from "lucide-react"; import { Button } from "@layout"; +import { ovenActionUrl, ovenCatalogKey, ovenTargetRepoRoot } from "@lib/oven-identity.mjs"; +import type { OvenSummary, RepoSummary } from "@lib/types"; import { Card, CardContent, @@ -33,15 +35,6 @@ import { CardTitle, } from "@layout"; -type OvenSummary = { - id: string; - name: string; - description: string; - builtIn: boolean; -}; - -type RepoSummary = { name: string; root: string }; - type DetailSection = { id: string; title: string; @@ -573,14 +566,19 @@ export function NewOvenPage() { cells: [], }); const [writeToken, setWriteToken] = useState(""); + const [repos, setRepos] = useState([]); + const [repoKey, setRepoKey] = useState(""); const [status, setStatus] = useState(""); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); useEffect(() => { - fetch("/api/ovens") - .then((response) => response.json()) - .then((payload) => setWriteToken(payload.writeToken || "")) + Promise.all([fetch("/api/ovens").then((response) => response.json()), fetch("/api/repos").then((response) => response.json())]) + .then(([ovensPayload, reposPayload]) => { + setWriteToken(ovensPayload.writeToken || ""); + setRepos(reposPayload.repos || []); + setRepoKey(reposPayload.repos?.[0]?.repoKey || ""); + }) .catch(() => setError("Could not initialize Oven saving.")); }, []); @@ -626,7 +624,7 @@ export function NewOvenPage() { "content-type": "application/json", "x-burnlist-token": writeToken, }, - body: JSON.stringify({ id, name, instructions, detail }), + body: JSON.stringify({ id, name, instructions, detail, ...(repoKey ? { repoKey } : {}) }), }); const payload = await response.json(); if (!response.ok) { @@ -657,6 +655,12 @@ export function NewOvenPage() {

+