diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 5389018..976df75 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -98,7 +98,7 @@ Usage: burnlist differential-testing sdk burnlist streaming-diff ... burnlist hooks [install|uninstall|status] [--agent codex,claude] [--untracked] (bare defaults to status) - burnlist oven ... + burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] burnlist ready [--repo ] diff --git a/ovens/differential-testing/engine/adapter-sdk.mjs b/ovens/differential-testing/engine/adapter-sdk.mjs index f06d2cb..9f1ac28 100644 --- a/ovens/differential-testing/engine/adapter-sdk.mjs +++ b/ovens/differential-testing/engine/adapter-sdk.mjs @@ -5,4 +5,4 @@ export { readDifferentialTestingWorkerState, } from "./worker-runtime.mjs"; -export const DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION = 3; +export const DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION = 4; diff --git a/ovens/differential-testing/engine/adapter-sdk.test.mjs b/ovens/differential-testing/engine/adapter-sdk.test.mjs index 56cd826..008fe90 100644 --- a/ovens/differential-testing/engine/adapter-sdk.test.mjs +++ b/ovens/differential-testing/engine/adapter-sdk.test.mjs @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; +import { readOvenEvents } from "../../../src/events/oven-event-store.mjs"; import { DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION, @@ -10,8 +11,8 @@ import { createDifferentialTestingWorker, } from "./adapter-sdk.mjs"; -test("SDK v3 persists one state before inbox deletion and handles projection-only events", async () => { - assert.equal(DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION, 3); +test("SDK v4 persists one state before inbox deletion and handles projection-only events", async () => { + assert.equal(DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION, 4); const root = await mkdtemp(join(tmpdir(), "burnlist-differential-worker-")); const inbox = []; const durableBeforeDelete = []; @@ -53,13 +54,18 @@ test("SDK v3 persists one state before inbox deletion and handles projection-onl assert.ok(durableBeforeDelete.every(Boolean)); assert.ok(projectionRuns >= 2); assert.equal(JSON.parse(await readFile(worker.statePath, "utf8")).schema, DIFFERENTIAL_TESTING_WORKER_STATE_SCHEMA); + const events = readOvenEvents(root, { ovenIds: ["differential-testing"] }); + assert.equal(events.length, 1); + assert.equal(events[0].kind, "iteration"); + assert.equal(events[0].phase, "complete"); + assert.equal(events[0].subjectId, first.scenarioId); worker.close(); } finally { await rm(root, { recursive: true, force: true }); } }); -test("SDK v3 serializes scenarios and coalesces one running successor", async () => { +test("SDK v4 serializes scenarios and coalesces one running successor", async () => { const root = await mkdtemp(join(tmpdir(), "burnlist-differential-serial-")); const inbox = []; const releases = []; @@ -99,13 +105,17 @@ test("SDK v3 serializes scenarios and coalesces one running successor", async () assert.equal(maxActive, 1); assert.equal(worker.scenarioStatus(first.scenarioId).status, "complete"); assert.equal(worker.scenarioStatus(second.scenarioId).status, "complete"); + const superseded = readOvenEvents(root, { ovenIds: ["differential-testing"] }) + .find((event) => event.phase === "superseded"); + assert.equal(superseded.payload.attempt, 1); + assert.equal(superseded.payload.requestId, first.requestId); worker.close(); } finally { await rm(root, { recursive: true, force: true }); } }); -test("SDK v3 retries transient work and recovers interrupted telemetry and projection", async () => { +test("SDK v4 retries transient work and recovers interrupted telemetry and projection", async () => { const root = await mkdtemp(join(tmpdir(), "burnlist-differential-restart-")); const request = job("1111111111111111", "a".repeat(64), 1); let telemetryRuns = 0; @@ -161,13 +171,16 @@ test("SDK v3 retries transient work and recovers interrupted telemetry and proje assert.ok(projectionRuns >= 2); assert.equal(worker.scenarioStatus(request.scenarioId).status, "complete"); assert.equal(worker.snapshot().projection.status, "complete"); + const events = readOvenEvents(root, { ovenIds: ["differential-testing"] }); + assert.equal(events.length, 2); + assert.deepEqual(new Set(events.map((event) => event.phase)), new Set(["retrying", "complete"])); worker.close(); } finally { await rm(root, { recursive: true, force: true }); } }); -test("SDK v3 quarantines an uncooperative timed-out runner and enforces one worker lock", async () => { +test("SDK v4 quarantines an uncooperative timed-out runner and enforces one worker lock", async () => { const root = await mkdtemp(join(tmpdir(), "burnlist-differential-timeout-")); const request = job("1111111111111111", "a".repeat(64), 1); const inbox = [entry(eventFor(request, true))]; @@ -193,7 +206,7 @@ test("SDK v3 quarantines an uncooperative timed-out runner and enforces one work } }); -test("SDK v3 rejects legacy state and delete callback configuration failures are fatal", async () => { +test("SDK v4 rejects legacy state and delete callback configuration failures are fatal", async () => { const legacyRoot = await mkdtemp(join(tmpdir(), "burnlist-differential-legacy-")); try { await writeJson(join(legacyRoot, ".local", "differential-testing", "state.json"), { @@ -225,6 +238,30 @@ test("SDK v3 rejects legacy state and delete callback configuration failures are } }); +test("SDK v4 keeps canonical worker success when observational publication returns a rejection", async () => { + const root = await mkdtemp(join(tmpdir(), "burnlist-differential-event-error-")); + const request = job("1111111111111111", "a".repeat(64), 1); + const inbox = [entry(eventFor(request, true))]; + const errors = []; + try { + const worker = fixtureWorker({ + root, + inbox, + emitOvenEvent() { return Promise.reject(new Error("event store unavailable")); }, + onOvenEventError(error, identity) { errors.push({ message: error.message, identity }); }, + }); + worker.start(); + await worker.idle(); + assert.equal(worker.scenarioStatus(request.scenarioId).status, "complete"); + assert.equal(errors.length, 1); + assert.equal(errors[0].message, "emitOvenEvent must complete synchronously."); + assert.equal(errors[0].identity.scenarioId, request.scenarioId); + worker.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function fixtureWorker({ root, inbox, ...overrides }) { return createDifferentialTestingWorker({ root, diff --git a/ovens/differential-testing/engine/worker-runtime.mjs b/ovens/differential-testing/engine/worker-runtime.mjs index 1d5232f..31a6e0e 100644 --- a/ovens/differential-testing/engine/worker-runtime.mjs +++ b/ovens/differential-testing/engine/worker-runtime.mjs @@ -11,6 +11,7 @@ import { writeFileSync, } from "node:fs"; import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { publishOvenEvent as publishRepoOvenEvent } from "../../../src/events/oven-event-store.mjs"; export const DIFFERENTIAL_TESTING_WORKER_STATE_SCHEMA = "burnlist-differential-testing-worker-state@1"; @@ -75,6 +76,8 @@ export function createDifferentialTestingWorker({ classifyTelemetryError, project, onFatal = () => {}, + emitOvenEvent = null, + onOvenEventError = () => {}, now = () => new Date().toISOString(), pollIntervalMs = 250, telemetryTimeoutMs = 5 * 60_000, @@ -108,6 +111,7 @@ export function createDifferentialTestingWorker({ [classifyTelemetryError, "classifyTelemetryError"], [project, "project"], [onFatal, "onFatal"], + [onOvenEventError, "onOvenEventError"], ]) { if (typeof callback !== "function") throw new Error(`${label} is required.`); } @@ -134,6 +138,8 @@ export function createDifferentialTestingWorker({ } const repoRoot = resolve(root); + if (emitOvenEvent !== null && typeof emitOvenEvent !== "function") throw new Error("emitOvenEvent must be a function or null."); + const eventPublisher = emitOvenEvent ?? ((event) => publishRepoOvenEvent(repoRoot, event)); const storeRoot = containedPath(repoRoot, storeDirectory, "Differential Testing store"); const statePath = containedPath(storeRoot, stateFile, "Differential Testing worker state"); const scratchRoot = resolve(storeRoot, ".scratch"); @@ -396,10 +402,13 @@ export function createDifferentialTestingWorker({ run: { id: runId, scratchDirectory: displayPath(repoRoot, scratchDirectory) }, }); state.telemetry.active = { scenarioId, requestId: request.requestId, runId, startedAt }; + const iterationAttempt = scenario.attempts; queueProjection("telemetry-running", scenarioId); bumpAndPersist(); let preserveScratch = false; let quarantined = false; + let iterationPhase = "failed"; + let iterationError = null; try { const staged = await runWithTimeout(runTelemetry, { root: repoRoot, @@ -408,6 +417,7 @@ export function createDifferentialTestingWorker({ request: structuredClone(request), }, telemetryTimeoutMs, telemetryAbortGraceMs); if (scenario.pendingRequest) { + iterationPhase = "superseded"; queuePendingScenario(scenario, { superseded: true }, timestamp(now(), "successor timestamp")); if (!state.telemetry.queue.includes(scenarioId)) state.telemetry.queue.push(scenarioId); } else { @@ -430,13 +440,16 @@ export function createDifferentialTestingWorker({ scenario.nextAttemptAt = null; scenario.publication = structuredClone(publication); scenario.run = { ...scenario.run, exitCode: staged.exitCode ?? null }; + iterationPhase = "complete"; } } catch (error) { + iterationError = String(error?.message ?? error).slice(0, 1_000); preserveScratch = error?.preserveScratch === true; if (error?.workerFatal === true) { quarantined = true; throw error; } else if (scenario.pendingRequest) { + iterationPhase = "superseded"; queuePendingScenario(scenario, { superseded: true, discardedError: error?.message ?? String(error) }, timestamp(now(), "successor timestamp")); if (!state.telemetry.queue.includes(scenarioId)) state.telemetry.queue.push(scenarioId); } else { @@ -453,11 +466,13 @@ export function createDifferentialTestingWorker({ scenario.run = { ...scenario.run, exitCode: error?.exitCode ?? null }; if (classification === "permanent" || scenario.attempts >= telemetryMaxAttempts) { scenario.status = "failed"; + iterationPhase = "failed"; scenario.finishedAt = timestamp(now(), "telemetry failure timestamp"); scenario.nextAttemptAt = null; if (classification !== "permanent") scenario.error += ` Telemetry retry exhausted after ${scenario.attempts} attempts.`; } else { scenario.status = "retrying"; + iterationPhase = "retrying"; scenario.startedAt = null; scenario.finishedAt = null; scenario.nextAttemptAt = timestampAfter(now(), retryDelay(scenario.attempts, telemetryRetryBaseMs, telemetryRetryMaxMs)); @@ -471,6 +486,30 @@ export function createDifferentialTestingWorker({ scenario.updatedAt = timestamp(now(), "scenario update timestamp"); queueProjection(`telemetry-${scenario.status}`, scenarioId); bumpAndPersist(); + try { + const emitted = eventPublisher({ + ovenId: "differential-testing", + subjectId: scenarioId, + kind: "iteration", + phase: iterationPhase, + cursor: runId, + occurredAt: scenario.updatedAt, + payload: { + requestId: request.requestId, + runId, + attempt: iterationAttempt, + status: iterationPhase, + published: iterationPhase === "complete", + ...(iterationError ? { error: iterationError } : {}), + }, + }); + if (emitted && typeof emitted.then === "function") { + void Promise.resolve(emitted).catch(() => {}); + throw new Error("emitOvenEvent must complete synchronously."); + } + } catch (error) { + try { onOvenEventError(error, { scenarioId, requestId: request.requestId, runId, phase: iterationPhase }); } catch {} + } } } } diff --git a/package.json b/package.json index 6e5bf96..308eae7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "burnlist": "bin/burnlist.mjs" }, "exports": { + "./oven-events": "./src/events/oven-events.mjs", "./differential-testing": "./ovens/differential-testing/engine/adapter-sdk.mjs", "./differential-testing/contract": "./ovens/differential-testing/engine/contract.mjs", "./differential-testing/transport": "./ovens/differential-testing/engine/transport.mjs" diff --git a/scripts/smoke-global-install.mjs b/scripts/smoke-global-install.mjs index e2bb4ca..431dca8 100755 --- a/scripts/smoke-global-install.mjs +++ b/scripts/smoke-global-install.mjs @@ -97,9 +97,14 @@ try { "createDifferentialTestingWorker", "readDifferentialTestingWorkerState", ]; - if (sdk.DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION !== 3 + if (sdk.DIFFERENTIAL_TESTING_ADAPTER_SDK_VERSION !== 4 || JSON.stringify(Object.keys(sdk).sort()) !== JSON.stringify(expected.sort())) process.exit(1); `]); + run(process.execPath, ["--input-type=module", "--eval", ` + const events = await import("burnlist/oven-events"); + const expected = ["normalizeOvenEvent", "publishOvenEvent", "readOvenEvents"]; + if (!expected.every((name) => typeof events[name] === "function")) process.exit(1); + `], { cwd: packageRoot }); run(cli, ["uninstall", "--global", "--purge"]); for (const agentDirectory of [".claude", ".agents"]) { diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 7cb65c0..540b35c 100755 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -36,10 +36,15 @@ const required = [ "src/cli/skills-register.mjs", "src/cli/oven-cli.mjs", "src/cli/registry-cli.mjs", + "src/events/oven-event-contract.mjs", + "src/events/oven-event-feed.mjs", + "src/events/oven-event-store.mjs", + "src/events/oven-events.mjs", "src/ovens/oven-contract.mjs", "src/server/burnlist-dashboard-server.mjs", "skills/burnlist/SKILL.md", "skills/burnlist/references/burnlist-creation.md", + "skills/burnlist/references/oven-event-coordination.md", "ovens/checklist/instructions.md", "ovens/differential-testing/instructions.md", "ovens/differential-testing/differential-testing.oven", diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 2342602..539cce6 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -1,5 +1,8 @@ export const verificationTestFiles = [ "src/server/dashboard-routes.test.mjs", + "src/server/oven-event-routes.test.mjs", + "src/events/oven-event-feed.test.mjs", + "src/events/oven-events.test.mjs", "src/server/custom-oven-view-routes.test.mjs", "src/server/custom-oven-index.test.mjs", "src/server/oven-routes.test.mjs", @@ -52,7 +55,6 @@ export const verificationTestFiles = [ "src/cli/oven-cli-stdout.test.mjs", "src/cli/oven-storage.test.mjs", "src/cli/umbrella.test.mjs", - "src/cli/streaming-diff-cli.test.mjs", "src/cli/hooks-config.test.mjs", "ovens/streaming-diff/engine/streaming-diff-hook-adapters.test.mjs", "src/server/oven-bindings.test.mjs", @@ -80,3 +82,9 @@ export const verificationTestFiles = [ "dashboard/src/lib/project-open.test.mjs", "dashboard/src/lib/oven-identity.test.mjs", ]; + +// These subprocess wall-clock assertions must not compete with the parallel +// test-file pool or host saturation can look like a hook timeout regression. +export const verificationSerialTestFiles = [ + "src/cli/streaming-diff-cli.test.mjs", +]; diff --git a/scripts/verify.mjs b/scripts/verify.mjs index e877385..b41f4e3 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -4,7 +4,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { assertBuiltInOven, assertBuiltInOvenSet, assertSkillSet } from "./verify-oven-assertions.mjs"; -import { verificationTestFiles } from "./verify-test-files.mjs"; +import { verificationSerialTestFiles, verificationTestFiles } from "./verify-test-files.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const packageJson = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")); @@ -217,8 +217,9 @@ function assertPublishablePackage() { } if (packageJson.exports?.["./differential-testing"] !== "./ovens/differential-testing/engine/adapter-sdk.mjs" || packageJson.exports?.["./differential-testing/contract"] !== "./ovens/differential-testing/engine/contract.mjs" - || packageJson.exports?.["./differential-testing/transport"] !== "./ovens/differential-testing/engine/transport.mjs") { - console.error("package.json does not expose the stable Differential Testing worker, contract, and transport subpaths."); + || packageJson.exports?.["./differential-testing/transport"] !== "./ovens/differential-testing/engine/transport.mjs" + || packageJson.exports?.["./oven-events"] !== "./src/events/oven-events.mjs") { + console.error("package.json does not expose the stable Differential Testing and Oven event subpaths."); process.exit(1); } if (packageJson.scripts?.postinstall !== "node scripts/register-skills.mjs") { @@ -265,6 +266,7 @@ assertSourceExcludes("dashboard/src/App.tsx", "function Detail(", "Dashboard sti assertSourceIncludes("dashboard/src/components/BurnOvens/BurnOvens.tsx", "New Oven", "Oven controls are missing."); assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", 'url.pathname === "/api/ovens"', "Oven API is missing."); assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", "/api\\/oven-data", "Read-only Oven data API is missing."); +assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", 'url.pathname === "/api/events"', "Replayable generic Oven event API is missing."); assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", 'url.pathname === "/api/repo-map"', "Read-only repository map API is missing."); assertSourceIncludes("src/server/repo-map.mjs", 'REPO_MAP_SCHEMA = "burnlist-repo-map@1"', "Repository map API does not expose its strict v1 schema."); assertSourceIncludes("src/server/burnlist-dashboard-server.mjs", 'assertKnownKeys(value, new Set(["id", "name", "instructions"]), "Oven")', "Oven creation does not reject fields outside the strict Oven contract."); @@ -379,6 +381,7 @@ assertSourceExcludes("dashboard/src/oven/differential-testing-render/differentia assertSourceExcludes("src/server/burnlist-dashboard-server.mjs", "exact comparator when used", "Fallback Run Burn still requests the superseded manual comparator workflow."); assertSourceExcludes("dashboard/src/components/BurnOvens/BurnOvens.tsx", "exact comparator when used", "React Run Burn still requests the superseded manual comparator workflow."); assertSourceIncludes("skills/burnlist/SKILL.md", "references/burnlist-creation.md", "The Burnlist skill does not route creation work."); +assertSourceIncludes("skills/burnlist/SKILL.md", "references/oven-event-coordination.md", "The Burnlist skill does not route coordinator event work."); assertSourceIncludes("src/cli/skills-register.mjs", 'join(home, ".claude", "skills")', "Global npm install does not use the Claude skill directory."); assertSourceIncludes("src/cli/skills-register.mjs", 'join(home, ".agents", "skills")', "Global npm install does not use the Codex skill directory."); assertSourceIncludes("bin/burnlist.mjs", "Usage:", "Burnlist CLI help is missing."); @@ -398,6 +401,7 @@ assertDifferentialTestingContractAssets(); assertPublishablePackage(); run(process.execPath, ["--test", ...verificationTestFiles]); +for (const file of verificationSerialTestFiles) run(process.execPath, ["--test", file]); run(process.execPath, ["dashboard/src/oven/test-support/run-oven-tests.mjs"]); const { diff --git a/skills/burnlist/SKILL.md b/skills/burnlist/SKILL.md index 7a00545..dada6aa 100644 --- a/skills/burnlist/SKILL.md +++ b/skills/burnlist/SKILL.md @@ -1,7 +1,7 @@ --- name: burnlist description: >- - Create, harden, execute, maintain, and repair repo-local Burnlists: turn goals into strict shrinking checklists, finalize draft-to-ready plans, run active items with atomic completion, split or reorder work, coordinate parent and lane Burnlists, manage lifecycle folders and terse completed ledgers, and repair the local dashboard/tracker. Use for both planning a new Burnlist and implementing or continuing an existing one. + Create, harden, execute, coordinate, maintain, and repair repo-local Burnlists: turn goals into strict shrinking checklists, run active items with atomic completion, split or reorder work, coordinate independent worker tasks and queues, monitor generic Oven progress events, manage lifecycle folders and terse completed ledgers, and repair the local dashboard/tracker. Use for planning, execution, multi-Burnlist coordination, or live worker supervision. --- # Burnlist @@ -14,6 +14,7 @@ Use one skill for the full Burnlist lifecycle. Burnlist is task state, not imple - **Creation mode:** when creating, hardening, restructuring, or readying a Burnlist, read `references/burnlist-creation.md` completely before editing Burnlist files. Creation owns `draft -> ready` and does not implement the planned work unless the user also asks to continue into execution. - **Execution mode:** when implementing or continuing a ready/in-progress Burnlist, follow the execution path below. Keep the hot working set small: the active item, relevant `goal.md` guardrails, current implementation evidence, and the state mutation being performed. +- **Coordination mode:** when selecting independent Burnlists, opening worker tasks, monitoring active workers, or assigning the next queue, read `references/oven-event-coordination.md` completely before acting. Retain exact task handles and use canonical Burnlist state plus Oven events; do not use model heartbeats as a status loop. - **Combined request:** finish and validate creation first, park the folder in `ready/`, then switch explicitly to execution mode and move the whole folder to `inprogress/`. ## Cold References @@ -30,6 +31,7 @@ Read references only when their trigger applies: - `references/designing-ovens.md`: choosing what an Oven should measure through proxy-resistant evidence, before touching the DSL. - `references/oven-authoring.md`: authoring or inspecting Ovens from the `burnlist oven` CLI, the widget/format vocabulary, and source-binding conventions. - `references/creating-ovens.md`: authoring a new .oven declarative source (grammar, elements, binding, themes, compile-to-IR walkthrough). +- `references/oven-event-coordination.md`: mandatory for multi-Burnlist worker coordination, generic Oven progress events, replayable subscriptions, and event-triggered coordinator wakeups. Do not load cold references for a normal single-item implementation unless needed. If a task touches a cold-rule area, read the matching reference before editing Burnlist state in that area. @@ -135,6 +137,8 @@ A burnlist in an unregistered repo is still visible when the dashboard is launch Ovens can also be authored and inspected from the CLI: `burnlist oven `. It writes only custom Ovens, keeps built-in Ovens read-only, reuses the same contract validation, and never executes instructions. `burnlist oven view ` renders the detail skeleton as a box-drawing grid for quick inspection. Read `references/oven-authoring.md` for the widget/format vocabulary and source-binding conventions. +Oven progress events are a separate observational surface under ignored repo-local state. They never replace Burnlist files or an Oven's proof artifacts. Checklist burns and Differential Testing worker iterations publish them automatically; future adapters use the same package API or `burnlist oven event`. The dashboard exposes one replayable `/api/events` feed across Ovens and repos. Read `references/oven-event-coordination.md` before using events to supervise worker tasks or wake a coordinator. + Do not embed repo/domain dashboards inside the Burnlist dashboard. Domain-specific viewers must live in their repo-local tools and may be linked or launched separately. Share state only through Burnlist lifecycle files, explicit URLs, or a narrow message contract; do not share CSS, layout code, routes, or polling loops. Read `references/burnlist-dashboard.md` only for dashboard/chart/log/timeline/repo-graph questions or dashboard repair. diff --git a/skills/burnlist/agents/openai.yaml b/skills/burnlist/agents/openai.yaml index 2104706..3ce531c 100644 --- a/skills/burnlist/agents/openai.yaml +++ b/skills/burnlist/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Burnlist" - short_description: "Create and run shrinking Burnlists" - default_prompt: "Use $burnlist to create, harden, or execute a repo-local Burnlist." + short_description: "Plan, run, and coordinate shrinking Burnlists" + default_prompt: "Use $burnlist to plan, execute, or coordinate repo-local Burnlists and their Oven progress." diff --git a/skills/burnlist/references/differential-testing-adapter-sdk.md b/skills/burnlist/references/differential-testing-adapter-sdk.md index 7f7a11e..fca358e 100644 --- a/skills/burnlist/references/differential-testing-adapter-sdk.md +++ b/skills/burnlist/references/differential-testing-adapter-sdk.md @@ -1,4 +1,4 @@ -# Differential Testing Adapter SDK v3 +# Differential Testing Adapter SDK v4 The SDK owns generic asynchronous worker mechanics without owning project evidence authority. @@ -32,7 +32,7 @@ import { The project still owns atomic bundle publication. The transport helper validates contained bindings and normalized records, then range-reads only the requested page; it never runs project evidence generation. -V3 has one durable inbox supplied by the project, one `state.json`, one worker lock, and one runtime. There is no staged/pending/acked/rejected tree, separate dispatcher state, separate projection state, legacy reader, migration, or fallback. +V4 has one durable inbox supplied by the project, one `state.json`, one worker lock, and one runtime. There is no staged/pending/acked/rejected tree, separate dispatcher state, separate projection state, legacy reader, migration, or fallback. After each persisted telemetry attempt it also publishes one generic observational Oven event; the event never becomes evidence authority. ## Project contract @@ -54,6 +54,8 @@ const worker = createDifferentialTestingWorker({ publishTelemetry, classifyTelemetryError, project, + emitOvenEvent, // optional synchronous test/custom transport seam + onOvenEventError, // optional non-fatal observer error reporting }); ``` @@ -88,6 +90,7 @@ The SDK owns: - one atomic `state.json` using `burnlist-differential-testing-worker-state@1` - one process lock per store - asynchronous projection scheduling after accepted events and telemetry transitions +- one idempotent `differential-testing/iteration` Oven event after each persisted telemetry attempt `runTelemetry` receives an `AbortSignal` and writes only to its scratch directory. `publishTelemetry` is synchronous and must atomically publish a result whose `requestId` matches the still-current job. Telemetry never controls exact-prefix retention. @@ -104,3 +107,5 @@ worker.close() ``` Use `onFatal(error)` to terminate a service host when persistence, callback configuration, or abort quarantine makes continued in-process execution unsafe. A restart reopens the last durable state and leaves the inbox event available when acceptance was not persisted. + +The default event publisher writes through `burnlist/oven-events` under ignored repo-local state. Event publication failure calls `onOvenEventError(error, identity)` and does not roll back canonical worker state. Read `references/oven-event-coordination.md` before subscribing a coordinator. diff --git a/skills/burnlist/references/oven-event-coordination.md b/skills/burnlist/references/oven-event-coordination.md new file mode 100644 index 0000000..c8a6d01 --- /dev/null +++ b/skills/burnlist/references/oven-event-coordination.md @@ -0,0 +1,90 @@ +# Oven Event Coordination + +Use this reference for a portfolio of Burnlists or worker tasks. It defines the progress signal, not project proof and not a new skill. Burnlist ships one `burnlist` skill; coordination is one mode inside it. + +## Authority boundary + +Canonical task state remains the Burnlist lifecycle folder and shrinking checklist. Canonical Oven evidence remains each adapter's published data. An Oven event is an immutable, observational notification that durable state changed. Never burn an item, accept an implementation, or claim proof from the event alone. + +Do not use an AI heartbeat to watch work. A non-model observer may keep the event stream open without spending model tokens. Wake the coordinator only for a real event, a worker decision request, a worker terminal state, or an explicit user check. + +## Generic event contract + +Every producer uses `burnlist-oven-event@1`: + +```json +{ + "schema": "burnlist-oven-event@1", + "authority": "observational", + "eventId": "oe1-", + "sequence": 1, + "ovenId": "future-oven", + "subjectId": "scenario-or-burnlist-id", + "kind": "iteration", + "phase": "complete", + "cursor": "producer-stable-logical-cursor", + "occurredAt": "2026-07-21T12:00:00.000Z", + "payload": {} +} +``` + +`eventId` is derived from Oven, subject, kind, phase, and cursor. The store assigns a monotonic sequence per repo and Oven. Repeating one logical event is idempotent and the first durable copy wins. Use a new stable cursor for every real progress boundary. Keep payloads compact and JSON-only; paths, captures, source data, secrets, and proof artifacts do not belong in the event. + +Publish from JavaScript after canonical state is durable: + +```js +import { publishOvenEvent } from "burnlist/oven-events"; + +publishOvenEvent(repoRoot, { + ovenId: "future-oven", + subjectId, + kind: "iteration", + phase: "complete", + cursor: runId, + payload: { result }, +}); +``` + +Shell adapters may use: + +```sh +burnlist oven event future-oven \ + --repo \ + --subject \ + --kind iteration \ + --phase complete \ + --cursor \ + --payload '{"result":"advanced"}' +``` + +Checklist `burn` publishes `item-burned/completed`. Differential Testing SDK v4 publishes one `iteration` event after every persisted telemetry attempt, including retry, terminal failure, completion, or supersession. A future Oven should emit at its own durable unit of progress rather than inventing an Oven-specific supervisor protocol. + +## Replayable feed + +The dashboard server exposes `GET /api/events`: + +- JSON is the default bounded snapshot; `total` counts returned events and `truncated` means one lookahead event existed. +- `Accept: text/event-stream` or `?stream=1` opens Server-Sent Events. +- Repeat `repoKey` and `ovenId` to restrict the subscription. +- Resume with the opaque cursor from the prior SSE `id` or JSON `cursor`, using `Last-Event-ID` or `after=`. +- Ignore SSE comments and `observer-error` for work acceptance. Only `oven-event` carries progress. + +The event delivery identity is `:::`. The replay cursor is a compact vector watermark, so one subscription can resume safely across repos and Ovens even when producer clocks differ. Persist the last handled replay cursor outside model context. Coalesce a burst before waking the coordinator, but do not drop distinct delivery identities. + +## Coordination loop + +1. Inventory ready and in-progress Burnlists, read their goals, harden weak queues, and choose only genuinely independent work. +2. Open worker tasks only with user authorization. Retain each exact task id, host id if supplied, Burnlist handle, repo root, and assigned scope. +3. Subscribe a non-model observer to the relevant `repoKey` and `ovenId` filters. Do not poll task prose as a heartbeat. +4. On an `oven-event`, read the canonical Burnlist and the exact worker status handle. Wake the coordinator only when there is new work to route, a blocker to resolve, a completed item to verify, or capacity to refill. +5. If a worker stopped while active work remains, inspect its final state. Resume or send a scoped follow-up only when the user's existing authority permits it; otherwise ask the user. +6. As capacity opens, assign the next independent ready Burnlist. Keep overlapping Burnlists serialized. +7. Stop monitoring when every assigned Burnlist is completed, genuinely blocked, or returned to the user. + +Do not infer worker status from a stale plan, task title, dashboard row, or event alone. Always reconcile task status, canonical Burnlist state, and the producer's current Oven data. + +## Wake bridge boundary + +Keep the Codex-specific bridge outside Oven adapters and outside the event schema. A supervisor may hold the SSE connection, persist the replay cursor and handled delivery identities, then use the supported Codex task resume/start interface to wake the coordinator with a compact batch of event identities. The bridge must not wake on keepalives, must deduplicate delivery identities, and must not mutate project or Burnlist state. + +This separation keeps future Ovens generic and lets non-Codex consumers use the same feed. diff --git a/src/cli/lifecycle-cli.test.mjs b/src/cli/lifecycle-cli.test.mjs index 5314019..58ade29 100644 --- a/src/cli/lifecycle-cli.test.mjs +++ b/src/cli/lifecycle-cli.test.mjs @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { execFileSync, spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { repoKey } from "../server/registry.mjs"; +import { readOvenEvents } from "../events/oven-event-store.mjs"; const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); const binPath = join(repoRoot, "bin", "burnlist.mjs"); @@ -265,6 +266,44 @@ test("burn removes an active item, appends its ledger entry, and can check the r assert.equal(burned.includes("- [ ] B1 | Inspect lifecycle output"), false); assert.match(burned, /- B1 \| \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2} \| Inspect lifecycle output/u); assert.match(output, /Burnlist check passed: 0 active, 1 completed\./u); + const [event] = readOvenEvents(context.repo, { ovenIds: ["checklist"] }); + assert.equal(event.kind, "item-burned"); + assert.equal(event.subjectId, result.id); + assert.deepEqual(event.payload, { + itemId: "B1", + title: "Inspect lifecycle output", + done: 1, + remaining: 0, + total: 1, + percent: 100, + }); + } finally { + context.cleanup(); + } +}); + +test("burn remains canonical when observational event publication fails", () => { + const context = fixture(); + try { + const result = newPlan(context); + addActiveItem(result.planPath, context.repo); + run(context, "ready", result.id); + run(context, "start", result.id); + const activePath = join(lifecycleFolder(context.repo, "inprogress", result.id), "burnlist.md"); + const stateRoot = join(context.repo, ".local", "burnlist"); + const outside = join(dirname(context.repo), "outside-lock"); + mkdirSync(stateRoot, { recursive: true }); + mkdirSync(outside); + symlinkSync(outside, join(stateRoot, ".lock"), "dir"); + const burned = spawnSync(process.execPath, [binPath, "burn", result.id, "B1", "--check"], { + cwd: context.repo, + encoding: "utf8", + env: { ...process.env, HOME: context.home }, + }); + assert.equal(burned.status, 0, burned.stderr); + assert.match(burned.stderr, /Burned B1, but could not publish its observational Oven event/u); + assert.equal(readFileSync(activePath, "utf8").includes("- [ ] B1 | Inspect lifecycle output"), false); + assert.match(readFileSync(activePath, "utf8"), /- B1 \| .+ \| Inspect lifecycle output/u); } finally { context.cleanup(); } diff --git a/src/cli/lifecycle-moves.mjs b/src/cli/lifecycle-moves.mjs index 100641c..c351885 100644 --- a/src/cli/lifecycle-moves.mjs +++ b/src/cli/lifecycle-moves.mjs @@ -1,5 +1,16 @@ import { randomBytes } from "node:crypto"; -import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs"; +import { + closeSync, + constants, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + rmdirSync, + writeFileSync, +} from "node:fs"; import { basename, dirname, join } from "node:path"; import { LIFECYCLES, @@ -9,6 +20,7 @@ import { validatePlan, } from "../server/plan-model.mjs"; import { safeStat, withLock } from "../server/fs-safe.mjs"; +import { publishOvenEvent } from "../events/oven-event-store.mjs"; export { withLock } from "../server/fs-safe.mjs"; @@ -18,15 +30,31 @@ function lifecycleRoot(repoRoot, lifecycle) { function atomicWrite(path, contents) { const temporary = join(dirname(path), `.${basename(path)}.${randomBytes(8).toString("hex")}.tmp`); + let descriptor; try { - writeFileSync(temporary, contents); + descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o666); + writeFileSync(descriptor, contents); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; renameSync(temporary, path); - } catch (error) { + fsyncDirectory(dirname(path)); + } finally { + if (descriptor !== undefined) closeSync(descriptor); rmSync(temporary, { force: true }); - throw error; } } +function fsyncDirectory(path) { + const descriptor = openSync(path, constants.O_RDONLY); + try { fsyncSync(descriptor); } finally { closeSync(descriptor); } +} + +function fsyncFile(path) { + const descriptor = openSync(path, constants.O_RDONLY); + try { fsyncSync(descriptor); } finally { closeSync(descriptor); } +} + function validateOrThrow(plan) { const issues = validatePlan(plan); const errors = issues.filter((issue) => issue.severity === "error"); @@ -197,6 +225,34 @@ function appendCompleted(lines, entry) { lines.splice(end < 0 ? lines.length : end, 0, entry); } +function checklistCompletion(id, item, completedAt, plan) { + const total = plan.items.length + plan.completed.length; + return { + ovenId: "checklist", + subjectId: id, + kind: "item-burned", + phase: "completed", + cursor: `${id}:${item.id}:${completedAt}`, + occurredAt: completedAt, + payload: { + itemId: item.id, + title: item.title, + done: plan.completed.length, + remaining: plan.items.length, + total, + percent: total ? Math.round((plan.completed.length / total) * 100) : 100, + }, + }; +} + +function printBurnCheck(plan, issues) { + for (const issue of issues) { + const stream = issue.severity === "error" ? console.error : console.warn; + stream(`${issue.severity.toUpperCase()}: ${issue.message}`); + } + console.log(`Burnlist check passed: ${plan.items.length} active, ${plan.completed.length} completed.`); +} + export function burnItem(repoRoot, id, itemId, check = false) { assertValidBurnlistId(id); const found = findBurnlistDir(repoRoot, id); @@ -204,32 +260,43 @@ export function burnItem(repoRoot, id, itemId, check = false) { throw new Error(`burnlist ${id} is not in inprogress; it is in ${found.lifecycle.folder}`); } const planPath = join(found.dir, "burnlist.md"); - return withLock(found.dir, () => { + const completion = withLock(found.dir, () => { const plan = parsePlan(planPath); - validateOrThrow(plan); + const currentIssues = validateOrThrow(plan); const item = plan.items.find((entry) => entry.id === itemId); - if (!item) throw new Error(`Active item ${itemId} was not found.`); + if (!item) { + const completed = plan.completed.find((entry) => entry.id === itemId); + if (!completed) throw new Error(`Active item ${itemId} was not found.`); + fsyncFile(planPath); + fsyncDirectory(dirname(planPath)); + if (check) printBurnCheck(plan, currentIssues); + return checklistCompletion(id, completed, completed.completedAt, plan); + } const lines = removeActiveItem(plan.markdown, itemId); - appendCompleted(lines, `- ${item.id} | ${localIsoTimestamp()} | ${item.title}`); + const completedAt = localIsoTimestamp(); + appendCompleted(lines, `- ${item.id} | ${completedAt} | ${item.title}`); const nextMarkdown = `${lines.join("\n").replace(/\s*$/u, "")}\n`; const temporary = join(dirname(planPath), `.${basename(planPath)}.${randomBytes(8).toString("hex")}.tmp`); let checked; try { - writeFileSync(temporary, nextMarkdown); + const descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o666); + try { + writeFileSync(descriptor, nextMarkdown); + fsyncSync(descriptor); + } finally { closeSync(descriptor); } checked = parsePlan(temporary); const issues = validateOrThrow(checked); renameSync(temporary, planPath); + fsyncDirectory(dirname(planPath)); checked = { plan: checked, issues }; } catch (error) { rmSync(temporary, { force: true }); throw error; } - if (!check) return true; - for (const issue of checked.issues) { - const stream = issue.severity === "error" ? console.error : console.warn; - stream(`${issue.severity.toUpperCase()}: ${issue.message}`); - } - console.log(`Burnlist check passed: ${checked.plan.items.length} active, ${checked.plan.completed.length} completed.`); - return true; + if (check) printBurnCheck(checked.plan, checked.issues); + return checklistCompletion(id, item, completedAt, checked.plan); }); + try { publishOvenEvent(repoRoot, completion); } + catch (error) { console.warn(`Burned ${itemId}, but could not publish its observational Oven event: ${error.message}`); } + return true; } diff --git a/src/cli/lifecycle-moves.test.mjs b/src/cli/lifecycle-moves.test.mjs index bebf888..44d327f 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 { spawn, spawnSync } 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 { readOvenEvents } from "../events/oven-event-store.mjs"; import { burnItem, closeLifecycle, findBurnlistDir, moveLifecycle, withLock } from "./lifecycle-moves.mjs"; function fixture() { @@ -57,6 +58,45 @@ function captureConsole(callback) { } } +test("burn retries publication from the completed ledger after directory sync fails", () => { + const context = fixture(); + const id = "260713-001"; + try { + const { planPath } = writePlan(context.root, "inprogress", id); + const moduleUrl = new URL("./lifecycle-moves.mjs", import.meta.url).href; + const script = [ + 'import fs from "node:fs";', + 'import { syncBuiltinESMExports } from "node:module";', + 'const originalFsync = fs.fsyncSync;', + 'let failed = false;', + 'fs.fsyncSync = (descriptor) => {', + ' if (!failed && fs.fstatSync(descriptor).isDirectory()) {', + ' failed = true;', + ' throw Object.assign(new Error("injected directory sync failure"), { code: "EIO" });', + ' }', + ' return originalFsync(descriptor);', + '};', + 'syncBuiltinESMExports();', + `const { burnItem } = await import(${JSON.stringify(moduleUrl)});`, + 'burnItem(process.argv[1], process.argv[2], "B1");', + ].join("\n"); + const failed = spawnSync(process.execPath, ["--input-type=module", "--eval", script, context.root, id], { + encoding: "utf8", + }); + assert.notEqual(failed.status, 0); + assert.match(failed.stderr, /injected directory sync failure/u); + assert.doesNotMatch(readFileSync(planPath, "utf8"), /- \[ \] B1/u); + assert.deepEqual(readOvenEvents(context.root), []); + + assert.equal(burnItem(context.root, id, "B1"), true); + const [event] = readOvenEvents(context.root, { ovenIds: ["checklist"] }); + assert.equal(event.payload.itemId, "B1"); + assert.equal(event.payload.done, 1); + } finally { + context.cleanup(); + } +}); + test("withLock takes over a dead owner and preserves a replacement owner on release", () => { const context = fixture(); try { diff --git a/src/cli/oven-cli.mjs b/src/cli/oven-cli.mjs index 906cdd5..c661e9c 100644 --- a/src/cli/oven-cli.mjs +++ b/src/cli/oven-cli.mjs @@ -10,6 +10,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOvenPackage, ovenId, ovenRevision } from "../ovens/oven-contract.mjs"; +import { publishOvenEvent } from "../events/oven-event-store.mjs"; import { scanXml } from "../ovens/dsl/xml-scan.mjs"; import { bindingStorePath, readBindingStore, removeBinding, writeBinding } from "../server/oven-bindings.mjs"; import { resolveCustomOvensDir } from "../server/oven-storage.mjs"; @@ -59,21 +60,30 @@ function bindingRepo() { const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const builtInOvensDir = resolve(packageRoot, "ovens"); const launchCwd = process.cwd(); -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 }, -); +let cachedOvenContext; -const { readOvenDir, discoverOvens, findOven } = createOvenCatalog({ - builtInOvensDir, - customOvensDir, - customRepoRoot, - unsafeOvensDir, -}); +function ovenContext() { + if (cachedOvenContext) return cachedOvenContext; + 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 }, + ); + cachedOvenContext = { + customRepoRoot, + customOvensDir, + unsafeOvensDir, + ...createOvenCatalog({ builtInOvensDir, customOvensDir, customRepoRoot, unsafeOvensDir }), + }; + return cachedOvenContext; +} + +const readOvenDir = (...args) => ovenContext().readOvenDir(...args); +const discoverOvens = (...args) => ovenContext().discoverOvens(...args); +const findOven = (...args) => ovenContext().findOven(...args); function printOven(oven) { let nodeCount = 0; @@ -125,6 +135,7 @@ Usage: burnlist oven bind [--repo ] burnlist oven unbind [--repo ] burnlist oven bindings [--repo ] + burnlist oven event --subject --kind --phase --cursor [--payload ] burnlist oven create --instructions [--oven ] [--name ] burnlist oven create --dir (reads instructions.md + .oven) burnlist oven create --package (JSON: {name?, instructions, oven}) @@ -138,6 +149,12 @@ Options: --dir

Directory containing instructions.md and .oven. --package

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

Repository whose local Oven bindings to use. + --subject Event subject such as a Burnlist or scenario id. + --kind Generic event kind. + --phase Generic event phase. + --cursor Stable cursor for one logical event. + --occurred-at Optional event timestamp; defaults to now. + --payload Optional compact JSON event payload. --ovens-dir

Custom Oven storage (default .local/burnlist/ovens). --unsafe-ovens-dir Permit --ovens-dir outside repo-local state. --force On create, replace an existing custom Oven. @@ -233,10 +250,42 @@ try { return; } + if (subcommand === "event") { + const id = positionals[0]; + const eventFlag = (name) => { + const value = flags.get(name); + return value && value !== "true" ? value : null; + }; + const subjectId = eventFlag("subject"); + const kind = eventFlag("kind"); + const phase = eventFlag("phase"); + const cursor = eventFlag("cursor"); + if (!id || !subjectId || !kind || !phase || !cursor) { + fail("Usage: burnlist oven event --subject --kind --phase --cursor [--payload ]"); + } + let payload = {}; + if (flags.has("payload")) { + try { payload = JSON.parse(flags.get("payload")); } + catch (error) { throw new Error(`--payload must be valid JSON: ${error.message}`); } + } + const result = publishOvenEvent(repoRoot(), { + ovenId: id, + subjectId, + kind, + phase, + cursor, + occurredAt: flags.get("occurred-at"), + payload, + }); + console.log(JSON.stringify({ created: result.created, event: result.event })); + return; + } + if (subcommand === "create" || subcommand === "update") { const pkg = resolvePackageInput({ flags, positionals, scaffold: subcommand === "create" }); assertCustomTarget(pkg.id, subcommand); const allowReplace = subcommand === "update" || flags.has("force"); + const { customRepoRoot, customOvensDir, unsafeOvensDir } = ovenContext(); 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`); @@ -253,6 +302,7 @@ try { const pkg = normalizeOvenPackage({ id, instructions: source.instructions, oven: rewriteRootOvenId(source.oven, id) }); const sourceRevision = ovenRevision(source); if (findOven(pkg.id)) throw new Error(`Oven ${pkg.id} already exists.`); + const { customRepoRoot, customOvensDir, unsafeOvensDir } = ovenContext(); 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 98bfb95..134b456 100644 --- a/src/cli/oven-cli.test.mjs +++ b/src/cli/oven-cli.test.mjs @@ -8,6 +8,7 @@ import { ovenRevision } from "../ovens/oven-contract.mjs"; import { starterOvenSource } from "../ovens/oven-starter.mjs"; import { resolveOvenPackageDir } from "../server/fs-safe.mjs"; import { assertCustomOvenPath } from "../server/oven-storage.mjs"; +import { readOvenEvents } from "../events/oven-event-store.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"); @@ -87,6 +88,50 @@ test("oven bind, bindings, and unbind persist a logical repo-local binding", () } finally { context.cleanup(); } }); +test("oven event publishes one idempotent generic repo-local event", () => { + const context = fixture(); + try { + const args = [ + "oven", "event", "future-oven", + "--repo", context.repo, + "--subject", "subject-1", + "--kind", "iteration", + "--phase", "complete", + "--cursor", "run-1", + "--occurred-at", "2026-07-21T12:00:00.000Z", + "--payload", '{"result":"advanced"}', + ]; + const first = JSON.parse(run(context, ...args)); + const retry = JSON.parse(run(context, ...args)); + assert.equal(first.created, true); + assert.equal(retry.created, false); + assert.equal(first.event.eventId, retry.event.eventId); + assert.deepEqual(readOvenEvents(context.repo), [first.event]); + } finally { context.cleanup(); } +}); + +test("oven event does not initialize or trust unrelated custom Oven storage", () => { + const context = fixture(); + const outside = join(dirname(context.repo), "outside-ovens"); + try { + mkdirSync(join(context.repo, ".local", "burnlist"), { recursive: true }); + mkdirSync(outside); + symlinkSync(outside, join(context.repo, ".local", "burnlist", "ovens"), "dir"); + const published = JSON.parse(run( + context, + "oven", "event", "future-oven", + "--repo", context.repo, + "--subject", "subject-1", + "--kind", "iteration", + "--phase", "complete", + "--cursor", "run-1", + )); + assert.equal(published.created, true); + assert.deepEqual(readOvenEvents(context.repo), [published.event]); + assert.deepEqual(readdirSync(outside), []); + } finally { context.cleanup(); } +}); + test("oven view reads optional fork lineage and rejects malformed sidecars", () => { const context = fixture(); const ovensDir = join(context.repo, ".local", "burnlist", "ovens"); diff --git a/src/cli/streaming-diff-cli.test.mjs b/src/cli/streaming-diff-cli.test.mjs index 4878ba5..4de8424 100644 --- a/src/cli/streaming-diff-cli.test.mjs +++ b/src/cli/streaming-diff-cli.test.mjs @@ -115,16 +115,16 @@ test("malformed hook flags still exit zero without using the regular flag parser } finally { context.cleanup(); } }); -test("hook stdin stops at its byte cap and timeout instead of waiting for EOF", { timeout: 4_000 }, async () => { +test("hook stdin stops at its byte cap and timeout instead of waiting for EOF", { timeout: 8_000 }, async () => { const context = fixture(); try { const oversized = hookProcess(context, "codex", "post"); oversized.stdin.write("x".repeat(300 * 1024)); - assert.deepEqual(await exitsWithin(oversized, 1_500), { code: 0, signal: null }); + assert.deepEqual(await exitsWithin(oversized, 3_000), { code: 0, signal: null }); const slow = hookProcess(context, "codex", "post"); slow.stdin.write("{"); - assert.deepEqual(await exitsWithin(slow, 1_500), { code: 0, signal: null }); + assert.deepEqual(await exitsWithin(slow, 3_000), { code: 0, signal: null }); const identity = resolveStreamingDiffIdentity({ cwd: context.root, session: "unknown-session" }); const cards = readJournal(identity.feedDir).cards.filter((card) => card.toolUseId === "unknown-tool-use"); @@ -134,7 +134,7 @@ test("hook stdin stops at its byte cap and timeout instead of waiting for EOF", } finally { context.cleanup(); } }); -test("a Codex payload with truncated path hints records a partial terminal card", () => { +test("a Codex payload with truncated path hints always leaves durable partial state", () => { const context = fixture(); try { const paths = Array.from({ length: 65 }, (_, index) => `file-${index}.txt`); @@ -146,7 +146,8 @@ test("a Codex payload with truncated path hints records a partial terminal card" writeFileSync(join(context.root, "file-0.txt"), "after\n"); hook(context, "codex", "post", payload); const identity = resolveStreamingDiffIdentity({ cwd: context.root, session: "codex-many" }); - const card = readJournal(identity.feedDir).cards.find((entry) => entry.toolUseId === "call-many" && entry.files.length > 0); + const card = readJournal(identity.feedDir).cards.find((entry) => entry.toolUseId === "call-many" && /path hints truncated/u.test(entry.partialReason ?? "")); + assert.ok(card); assert.equal(card.status, "partial"); assert.match(card.partialReason, /path hints truncated/u); } finally { context.cleanup(); } diff --git a/src/events/oven-event-contract.mjs b/src/events/oven-event-contract.mjs new file mode 100644 index 0000000..ac9bb78 --- /dev/null +++ b/src/events/oven-event-contract.mjs @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto"; +import { ovenId } from "../ovens/oven-contract.mjs"; + +export const OVEN_EVENT_SCHEMA = "burnlist-oven-event@1"; +export const OVEN_EVENT_AUTHORITY = "observational"; +export const OVEN_EVENT_MAX_BYTES = 32 * 1024; + +const eventIdPattern = /^oe1-[a-f0-9]{64}$/u; +const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +function plainObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactKeys(value, keys, label) { + if (!plainObject(value)) throw new Error(`${label} must be an object.`); + for (const key of Object.keys(value)) { + if (!keys.has(key)) throw new Error(`${label} contains unsupported field "${key}".`); + } +} + +function boundedText(value, label, maxLength) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required.`); + if (value !== value.trim() || value.length > maxLength || /[\u0000-\u001f\u007f]/u.test(value)) { + throw new Error(`${label} must be trimmed printable text no longer than ${maxLength} characters.`); + } + return value; +} + +function slug(value, label) { + const normalized = boundedText(value, label, 48); + if (!slugPattern.test(normalized)) throw new Error(`${label} must be a lowercase slug.`); + return normalized; +} + +function timestamp(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) { + throw new Error("Oven event occurredAt must be an ISO timestamp."); + } + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) throw new Error("Oven event occurredAt must be an ISO timestamp."); + return new Date(parsed).toISOString(); +} + +function jsonValue(value, label, depth = 0) { + if (depth > 8) throw new Error(`${label} is nested too deeply.`); + if (value === null || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error(`${label} contains a non-finite number.`); + return value; + } + if (typeof value === "string") { + if (value.length > 4_096) throw new Error(`${label} contains text longer than 4096 characters.`); + return value; + } + if (Array.isArray(value)) { + if (value.length > 128) throw new Error(`${label} contains more than 128 array entries.`); + return value.map((entry, index) => jsonValue(entry, `${label}[${index}]`, depth + 1)); + } + if (!plainObject(value)) throw new Error(`${label} must contain JSON values only.`); + const entries = Object.entries(value); + if (entries.length > 64) throw new Error(`${label} contains more than 64 fields.`); + const normalizedEntries = []; + for (const [key, entry] of entries) { + if (!key || key.length > 80 || /[\u0000-\u001f\u007f]/u.test(key)) throw new Error(`${label} contains an invalid field name.`); + normalizedEntries.push([key, jsonValue(entry, `${label}.${key}`, depth + 1)]); + } + return Object.fromEntries(normalizedEntries); +} + +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); +} + +export function serializeOvenEvent(value) { + return `${JSON.stringify(value)}\n`; +} + +function assertSerializedSize(value, suffix = "") { + if (Buffer.byteLength(serializeOvenEvent(value)) > OVEN_EVENT_MAX_BYTES) { + throw new Error(`Oven event is larger than ${OVEN_EVENT_MAX_BYTES} bytes${suffix}.`); + } +} + +export function ovenEventId(value) { + const identity = canonicalJson({ + schema: OVEN_EVENT_SCHEMA, + ovenId: ovenId(value.ovenId), + subjectId: boundedText(value.subjectId, "Oven event subjectId", 160), + kind: slug(value.kind, "Oven event kind"), + phase: slug(value.phase, "Oven event phase"), + cursor: boundedText(value.cursor, "Oven event cursor", 200), + }); + return `oe1-${createHash("sha256").update(identity).digest("hex")}`; +} + +export function normalizeOvenEvent(value, { now = () => new Date().toISOString() } = {}) { + exactKeys(value, new Set(["ovenId", "subjectId", "kind", "phase", "cursor", "occurredAt", "payload"]), "Oven event input"); + const event = { + schema: OVEN_EVENT_SCHEMA, + authority: OVEN_EVENT_AUTHORITY, + eventId: ovenEventId(value), + ovenId: ovenId(value.ovenId), + subjectId: boundedText(value.subjectId, "Oven event subjectId", 160), + kind: slug(value.kind, "Oven event kind"), + phase: slug(value.phase, "Oven event phase"), + cursor: boundedText(value.cursor, "Oven event cursor", 200), + occurredAt: timestamp(value.occurredAt ?? now()), + payload: (() => { + const payload = value.payload ?? {}; + if (!plainObject(payload)) throw new Error("Oven event payload must be an object."); + return jsonValue(payload, "Oven event payload"); + })(), + }; + assertSerializedSize(event); + return event; +} + +export function assertOvenEvent(value) { + exactKeys(value, new Set([ + "schema", "authority", "eventId", "sequence", "ovenId", "subjectId", "kind", "phase", "cursor", "occurredAt", "payload", + ]), "Oven event"); + if (value.schema !== OVEN_EVENT_SCHEMA || value.authority !== OVEN_EVENT_AUTHORITY || !eventIdPattern.test(value.eventId ?? "")) { + throw new Error("Oven event schema, authority, or eventId is invalid."); + } + if (!Number.isSafeInteger(value.sequence) || value.sequence < 1) throw new Error("Oven event sequence must be a positive integer."); + const normalized = { ...normalizeOvenEvent({ + ovenId: value.ovenId, + subjectId: value.subjectId, + kind: value.kind, + phase: value.phase, + cursor: value.cursor, + occurredAt: value.occurredAt, + payload: value.payload, + }, { now: () => value.occurredAt }), sequence: value.sequence }; + assertSerializedSize(normalized, " after sequencing"); + if (normalized.eventId !== value.eventId || canonicalJson(normalized) !== canonicalJson(value)) { + throw new Error("Oven event does not match its canonical identity."); + } + return normalized; +} diff --git a/src/events/oven-event-feed.mjs b/src/events/oven-event-feed.mjs new file mode 100644 index 0000000..90d79de --- /dev/null +++ b/src/events/oven-event-feed.mjs @@ -0,0 +1,371 @@ +import { ovenId } from "../ovens/oven-contract.mjs"; +import { + openOvenEventStreams, + OVEN_EVENT_MAX_READ_EVENTS, + OVEN_EVENT_MAX_READ_STREAMS, +} from "./oven-event-store.mjs"; + +const replayCursorPattern = /^oev1-[A-Za-z0-9_-]{2,8192}$/u; +const watermarkKeyPattern = /^[a-f0-9]{12}\/[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const repoKeyPattern = /^[a-f0-9]{12}$/u; +const allowedQueryKeys = new Set(["after", "limit", "ovenId", "repoKey", "stream"]); +const MAX_STREAM_SUBSCRIBERS = 32; +const MAX_FEED_REPOS = 32; +const MAX_FEED_STREAMS = 64; +const MAX_FEED_WARNINGS = 64; +let activeStreamSubscribers = 0; + +function requestError(message, status = 400) { + return Object.assign(new Error(message), { status }); +} + +function unique(values) { + return [...new Set(values)]; +} + +function one(url, name) { + const values = url.searchParams.getAll(name); + if (values.length > 1) throw requestError(`${name} must be supplied at most once.`); + return values[0] ?? null; +} + +function selectedRepos(repos, repoKeys) { + if (!Array.isArray(repos)) throw new Error("Oven event repositories must be an array."); + if (repoKeys.length > MAX_FEED_REPOS) throw requestError(`At most ${MAX_FEED_REPOS} repositories may be selected.`); + if (!repoKeys.length) { + if (repos.length > MAX_FEED_REPOS) throw requestError(`The event feed spans more than ${MAX_FEED_REPOS} repositories; filter by repoKey.`, 413); + return repos; + } + const known = new Map(repos.map((repo) => [repo.repoKey, repo])); + return repoKeys.map((key) => { + if (!repoKeyPattern.test(key)) throw requestError("repoKey must be a lowercase 12-character hexadecimal key."); + const repo = known.get(key); + if (!repo) throw requestError(`Unknown repository key: ${key}`, 404); + return repo; + }); +} + +function validatedWatermarks(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw requestError(`${label} is invalid.`); + const entries = Object.entries(value); + if (entries.length > MAX_FEED_STREAMS) throw requestError(`${label} contains too many stream watermarks.`); + const result = {}; + for (const [key, sequence] of entries) { + if (!watermarkKeyPattern.test(key) || !Number.isSafeInteger(sequence) || sequence < 0) { + throw requestError(`${label} is invalid.`); + } + result[key] = sequence; + } + return result; +} + +export function decodeOvenEventReplayCursor(value) { + if (!value) return {}; + if (!replayCursorPattern.test(value)) throw requestError("after must be a valid Oven event replay cursor."); + let decoded; + try { decoded = JSON.parse(Buffer.from(value.slice(5), "base64url").toString("utf8")); } + catch { throw requestError("after must be a valid Oven event replay cursor."); } + return validatedWatermarks(decoded, "Oven event replay cursor"); +} + +export function encodeOvenEventReplayCursor(watermarks) { + const validated = validatedWatermarks(watermarks, "Oven event replay cursor"); + const ordered = {}; + for (const key of Object.keys(validated).sort()) ordered[key] = validated[key]; + const cursor = `oev1-${Buffer.from(JSON.stringify(ordered)).toString("base64url")}`; + if (!replayCursorPattern.test(cursor)) throw requestError("Oven event replay cursor is too large."); + return cursor; +} + +export function ovenEventFeedSelection(url, repos, headers = {}) { + for (const key of url.searchParams.keys()) { + if (!allowedQueryKeys.has(key)) throw requestError(`Unsupported Oven event query parameter: ${key}`); + } + const repoValues = url.searchParams.getAll("repoKey"); + const ovenValues = url.searchParams.getAll("ovenId"); + if (repoValues.length > MAX_FEED_REPOS) throw requestError(`At most ${MAX_FEED_REPOS} repoKey values may be supplied.`); + if (ovenValues.length > MAX_FEED_STREAMS) throw requestError(`At most ${MAX_FEED_STREAMS} ovenId values may be supplied.`); + const repoKeys = unique(repoValues); + const ovenIds = unique(ovenValues).map(ovenId); + const headerValue = headers["last-event-id"]; + const headerAfter = typeof headerValue === "string" ? headerValue.trim() : ""; + const queryAfter = one(url, "after") ?? ""; + if (headerAfter && queryAfter && headerAfter !== queryAfter) throw requestError("after and Last-Event-ID disagree."); + const limitText = one(url, "limit"); + const limit = limitText === null ? 256 : Number(limitText); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > OVEN_EVENT_MAX_READ_EVENTS) { + throw requestError(`limit must be an integer from 1 to ${OVEN_EVENT_MAX_READ_EVENTS}.`); + } + const streamValue = one(url, "stream"); + if (streamValue !== null && streamValue !== "1") throw requestError("stream must be 1 when supplied."); + return { + repos: selectedRepos(repos, repoKeys), + ovenIds, + watermarks: decodeOvenEventReplayCursor(headerAfter || queryAfter || null), + limit, + stream: streamValue === "1", + }; +} + +function repoAfterSequences(watermarks, repoKey) { + const result = {}; + const prefix = `${repoKey}/`; + for (const [key, sequence] of Object.entries(watermarks)) { + if (key.startsWith(prefix)) result[key.slice(prefix.length)] = sequence; + } + return result; +} + +function delivery(repo, event) { + return { + deliveryId: `${repo.repoKey}:${event.ovenId}:${event.sequence}:${event.eventId}`, + repoKey: repo.repoKey, + repo: repo.name, + ...event, + }; +} + +function warningCollector() { + const warnings = []; + const seen = new Set(); + return { + warnings, + add(repoKey, error) { + const warning = { repoKey, code: error?.code ?? "EOBSERVER", error: error?.message ?? String(error) }; + const signature = JSON.stringify(warning); + if (seen.has(signature)) return; + seen.add(signature); + if (warnings.length < MAX_FEED_WARNINGS) warnings.push(warning); + }, + }; +} + +function compareHeads(left, right) { + return left.current.occurredAt.localeCompare(right.current.occurredAt) || left.key.localeCompare(right.key); +} + +function heapDown(heap, start) { + let index = start; + while (true) { + const left = index * 2 + 1; + const right = left + 1; + let smallest = index; + if (left < heap.length && compareHeads(heap[left], heap[smallest]) < 0) smallest = left; + if (right < heap.length && compareHeads(heap[right], heap[smallest]) < 0) smallest = right; + if (smallest === index) return; + [heap[index], heap[smallest]] = [heap[smallest], heap[index]]; + index = smallest; + } +} + +export function readOvenEventDeliveries(repos, { ovenIds = [], watermarks = {}, limit = 256 } = {}) { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > OVEN_EVENT_MAX_READ_EVENTS) { + throw requestError(`limit must be an integer from 1 to ${OVEN_EVENT_MAX_READ_EVENTS}.`); + } + const observed = warningCollector(); + const sources = []; + const streamKeys = []; + let streamCount = 0; + for (const repo of repos) { + let streams; + try { + streams = openOvenEventStreams(repo.root, { + ovenIds, + afterSequences: repoAfterSequences(watermarks, repo.repoKey), + maxStreams: OVEN_EVENT_MAX_READ_STREAMS, + onInvalid: (error) => observed.add(repo.repoKey, error), + }); + } catch (error) { + if (error?.code === "ESTREAMLIMIT") throw requestError(error.message, 413); + observed.add(repo.repoKey, error); + continue; + } + if (streamCount + streams.length > MAX_FEED_STREAMS) { + throw requestError(`The event feed spans more than ${MAX_FEED_STREAMS} streams; filter by repoKey or ovenId.`, 413); + } + streamCount += streams.length; + for (const reader of streams) { + streamKeys.push(`${repo.repoKey}/${reader.ovenId}`); + const event = reader.next(); + if (event) sources.push({ key: `${repo.repoKey}/${event.ovenId}`, repo, reader, current: delivery(repo, event) }); + } + } + const heap = sources; + for (let index = Math.floor(heap.length / 2) - 1; index >= 0; index -= 1) heapDown(heap, index); + const deliveries = []; + while (heap.length && deliveries.length < limit + 1) { + const head = heap[0]; + deliveries.push(head.current); + const next = head.reader.next(); + if (next) head.current = delivery(head.repo, next); + else { + heap[0] = heap.at(-1); + heap.pop(); + } + if (heap.length) heapDown(heap, 0); + } + return { deliveries, warnings: observed.warnings, streamKeys }; +} + +function advancedWatermarks(watermarks, deliveries) { + const next = { ...watermarks }; + for (const item of deliveries) { + const key = `${item.repoKey}/${item.ovenId}`; + next[key] = Math.max(next[key] ?? 0, item.sequence); + } + return next; +} + +function scopedWatermarks(watermarks, streamKeys = [], deliveries = []) { + const next = {}; + for (const key of streamKeys) next[key] = watermarks[key] ?? 0; + for (const item of deliveries) { + const key = `${item.repoKey}/${item.ovenId}`; + next[key] = Math.max(next[key] ?? 0, item.sequence); + } + return next; +} + +function writeSse(res, event, data, id) { + return res.write(`${id ? `id: ${id}\n` : ""}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +export function serveOvenEventFeed({ + req, + res, + url, + repos, + json, + scanIntervalMs = 500, + keepAliveMs = 15_000, + maxSubscribers = MAX_STREAM_SUBSCRIBERS, + timers = { setInterval, clearInterval }, + readDeliveries = readOvenEventDeliveries, +}) { + const selection = ovenEventFeedSelection(url, repos, req.headers); + const acceptsSse = String(req.headers.accept ?? "").includes("text/event-stream"); + const streaming = selection.stream || acceptsSse; + if (!Number.isSafeInteger(maxSubscribers) || maxSubscribers < 1 || maxSubscribers > MAX_STREAM_SUBSCRIBERS) { + throw new Error(`Oven event subscriber limit must be from 1 to ${MAX_STREAM_SUBSCRIBERS}.`); + } + if (streaming && activeStreamSubscribers >= maxSubscribers) throw requestError("Too many Oven event subscribers.", 429); + const initial = readDeliveries(selection.repos, selection); + if (!streaming) { + const events = initial.deliveries.slice(0, selection.limit); + json(res, 200, { + schema: "burnlist-oven-event-feed@1", + generatedAt: new Date().toISOString(), + cursor: encodeOvenEventReplayCursor(scopedWatermarks(selection.watermarks, initial.streamKeys, events)), + total: events.length, + truncated: events.length < initial.deliveries.length, + events, + warnings: initial.warnings, + }); + return; + } + activeStreamSubscribers += 1; + let closed = false; + let waitingDrain = false; + let scanTimer = null; + let keepAliveTimer = null; + let watermarks = scopedWatermarks(selection.watermarks, initial.streamKeys); + let pending = []; + let pendingIndex = 0; + const reportedWarnings = new Set(); + + const cleanup = () => { + if (closed) return; + closed = true; + activeStreamSubscribers = Math.max(0, activeStreamSubscribers - 1); + if (scanTimer !== null) timers.clearInterval(scanTimer); + if (keepAliveTimer !== null) timers.clearInterval(keepAliveTimer); + res.off?.("drain", onDrain); + res.off?.("close", cleanup); + res.off?.("error", cleanup); + res.off?.("finish", cleanup); + req.off?.("aborted", cleanup); + }; + const closeStream = () => { + cleanup(); + if (!res.destroyed) res.destroy?.(); + }; + const onDrain = () => { + waitingDrain = false; + pump(); + }; + const queueBatch = (batch) => { + for (const warning of batch.warnings ?? []) { + const signature = JSON.stringify(warning); + if (reportedWarnings.has(signature) || reportedWarnings.size >= MAX_FEED_WARNINGS) continue; + reportedWarnings.add(signature); + pending.push({ type: "warning", value: warning }); + } + for (const item of batch.deliveries.slice(0, selection.limit)) pending.push({ type: "delivery", value: item }); + }; + function pump() { + if (closed || waitingDrain) return; + try { + while (!closed && pendingIndex < pending.length) { + const frame = pending[pendingIndex]; + pendingIndex += 1; + let accepted; + if (frame.type === "delivery") { + watermarks = advancedWatermarks(watermarks, [frame.value]); + accepted = writeSse(res, "oven-event", frame.value, encodeOvenEventReplayCursor(watermarks)); + } else accepted = writeSse(res, "observer-error", frame.value); + if (!accepted) { + waitingDrain = true; + res.once("drain", onDrain); + return; + } + } + pending = []; + pendingIndex = 0; + } catch { closeStream(); } + } + const scan = () => { + if (closed || waitingDrain || pendingIndex < pending.length) return; + try { queueBatch(readDeliveries(selection.repos, { ...selection, watermarks })); } + catch (error) { + queueBatch({ deliveries: [], warnings: [{ code: error?.code ?? "EOBSERVER", error: error?.message ?? String(error) }] }); + } + pump(); + }; + const keepAlive = () => { + if (closed || waitingDrain || pendingIndex < pending.length) return; + try { + if (!res.write(`: keepalive ${Date.now()}\n\n`)) { + waitingDrain = true; + res.once("drain", onDrain); + } + } catch { closeStream(); } + }; + + try { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + res.once("close", cleanup); + res.once("error", cleanup); + res.once("finish", cleanup); + req.once?.("aborted", cleanup); + queueBatch(initial); + if (!res.write("retry: 1000\n\n")) { + waitingDrain = true; + res.once("drain", onDrain); + } else pump(); + if (!closed) { + scanTimer = timers.setInterval(scan, scanIntervalMs); + keepAliveTimer = timers.setInterval(keepAlive, keepAliveMs); + scanTimer?.unref?.(); + keepAliveTimer?.unref?.(); + } + } catch (error) { + cleanup(); + if (res.headersSent) res.destroy?.(); + else throw error; + } +} diff --git a/src/events/oven-event-feed.test.mjs b/src/events/oven-event-feed.test.mjs new file mode 100644 index 0000000..750eedb --- /dev/null +++ b/src/events/oven-event-feed.test.mjs @@ -0,0 +1,298 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + decodeOvenEventReplayCursor, + encodeOvenEventReplayCursor, + readOvenEventDeliveries, + serveOvenEventFeed, +} from "./oven-event-feed.mjs"; +import { publishOvenEvent } from "./oven-event-store.mjs"; + +function fixture(t, repoKey = "aaaaaaaaaaaa") { + const root = mkdtempSync(join(tmpdir(), "burnlist-event-feed-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + return { root, repoKey, name: `repo-${repoKey}` }; +} + +function input(cursor, overrides = {}) { + return { + ovenId: "future-oven", + subjectId: "subject-1", + kind: "iteration", + phase: "complete", + cursor, + occurredAt: "2026-07-21T12:00:00.000Z", + payload: { cursor }, + ...overrides, + }; +} + +function request(headers = {}) { + return Object.assign(new EventEmitter(), { headers }); +} + +class FakeResponse extends EventEmitter { + constructor(results = []) { + super(); + this.results = [...results]; + this.writes = []; + this.destroyed = false; + this.headersSent = false; + } + + writeHead(status, headers) { + this.status = status; + this.headers = headers; + this.headersSent = true; + } + + write(value) { + this.writes.push(value); + const result = this.results.length ? this.results.shift() : true; + if (result instanceof Error) throw result; + return result; + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.emit("close"); + } +} + +function fakeTimers() { + const handles = []; + return { + handles, + setInterval(callback) { + const handle = { callback, cleared: false, unref() {} }; + handles.push(handle); + return handle; + }, + clearInterval(handle) { handle.cleared = true; }, + }; +} + +function delivery(repo, cursor, sequence) { + const eventId = `oe1-${String(sequence).padStart(64, "0")}`; + return { + deliveryId: `${repo.repoKey}:future-oven:${sequence}:${eventId}`, + repoKey: repo.repoKey, + repo: repo.name, + schema: "burnlist-oven-event@1", + authority: "observational", + eventId, + sequence, + ovenId: "future-oven", + subjectId: "subject-1", + kind: "iteration", + phase: "complete", + cursor, + occurredAt: "2026-07-21T12:00:00.000Z", + payload: {}, + }; +} + +test("unfiltered feed discovery treats ovenIds=[] as every Oven stream", (t) => { + const repo = fixture(t); + publishOvenEvent(repo.root, input("future")); + publishOvenEvent(repo.root, input("other", { + ovenId: "other-oven", + occurredAt: "2026-07-21T12:01:00.000Z", + })); + const batch = readOvenEventDeliveries([repo], { ovenIds: [], limit: 10 }); + assert.deepEqual(batch.deliveries.map((event) => event.cursor), ["future", "other"]); + assert.deepEqual(batch.deliveries.map((event) => event.ovenId), ["future-oven", "other-oven"]); +}); + +test("JSON feed paginates with vector cursors while preserving each stream sequence", (t) => { + const repoA = fixture(t, "aaaaaaaaaaaa"); + const repoB = fixture(t, "bbbbbbbbbbbb"); + publishOvenEvent(repoA.root, input("a-1")); + publishOvenEvent(repoA.root, input("a-2", { occurredAt: "2026-07-21T11:00:00.000Z" })); + publishOvenEvent(repoB.root, input("b-1", { occurredAt: "2026-07-21T11:30:00.000Z" })); + let first; + serveOvenEventFeed({ + req: request(), + res: {}, + url: new URL("http://localhost/api/events?limit=2"), + repos: [repoA, repoB], + json(_res, status, body) { first = { status, body }; }, + }); + assert.equal(first.status, 200); + assert.equal(first.body.total, 2); + assert.equal(first.body.truncated, true); + assert.deepEqual(first.body.events.map((event) => event.cursor), ["b-1", "a-1"]); + + let replay; + serveOvenEventFeed({ + req: request(), + res: {}, + url: new URL(`http://localhost/api/events?after=${encodeURIComponent(first.body.cursor)}`), + repos: [repoA, repoB], + json(_res, status, body) { replay = { status, body }; }, + }); + assert.equal(replay.status, 200); + assert.deepEqual(replay.body.events.map((event) => event.cursor), ["a-2"]); +}); + +test("feed bounds vector cursors and discovered stream count", (t) => { + const watermarks = Object.fromEntries(Array.from( + { length: 65 }, + (_, index) => [`aaaaaaaaaaaa/oven-${index}`, index], + )); + assert.throws(() => encodeOvenEventReplayCursor(watermarks), /too many stream watermarks/u); + + const repo = fixture(t); + for (let index = 0; index < 65; index += 1) { + mkdirSync(join(repo.root, ".local", "burnlist", "events", `oven-${index}`, "sequence"), { recursive: true }); + } + assert.throws( + () => readOvenEventDeliveries([repo]), + (error) => error.status === 413 && /limited to 64 streams/u.test(error.message), + ); +}); + +test("JSON feed drops stale cursor streams when serving a new stream", (t) => { + const cursor = encodeOvenEventReplayCursor(Object.fromEntries(Array.from( + { length: 64 }, + (_, index) => [`aaaaaaaaaaaa/oven-${index}`, 1], + ))); + const repoA = fixture(t, "aaaaaaaaaaaa"); + const repoB = fixture(t, "bbbbbbbbbbbb"); + publishOvenEvent(repoB.root, input("b-1")); + let result; + serveOvenEventFeed({ + req: request(), + res: {}, + url: new URL(`http://localhost/api/events?after=${encodeURIComponent(cursor)}`), + repos: [repoA, repoB], + json(_res, status, body) { result = { status, body }; }, + }); + assert.equal(result.status, 200); + assert.deepEqual(result.body.events.map((event) => event.cursor), ["b-1"]); + const watermarks = decodeOvenEventReplayCursor(result.body.cursor); + assert.equal(watermarks["bbbbbbbbbbbb/future-oven"], 1); + assert.equal(Object.keys(watermarks).some((key) => key.startsWith("aaaaaaaaaaaa/")), false); +}); + +test("SSE feed drops stale cursor streams when serving a new stream", (t) => { + const cursor = encodeOvenEventReplayCursor(Object.fromEntries(Array.from( + { length: 64 }, + (_, index) => [`aaaaaaaaaaaa/oven-${index}`, 1], + ))); + const repoA = fixture(t, "aaaaaaaaaaaa"); + const repoB = fixture(t, "bbbbbbbbbbbb"); + publishOvenEvent(repoB.root, input("b-1")); + const req = request({ accept: "text/event-stream" }); + const res = new FakeResponse(); + serveOvenEventFeed({ + req, + res, + url: new URL(`http://localhost/api/events?stream=1&after=${encodeURIComponent(cursor)}`), + repos: [repoA, repoB], + json() {}, + timers: fakeTimers(), + }); + assert.equal(res.destroyed, false); + assert.match(res.writes.join(""), /event: oven-event/u); + const id = res.writes.join("").match(/^id: (.+)$/mu)?.[1]; + assert.ok(id); + const watermarks = decodeOvenEventReplayCursor(id); + assert.equal(watermarks["bbbbbbbbbbbb/future-oven"], 1); + req.emit("aborted"); +}); + +test("SSE subscriber cap is released on disconnect and write failure", () => { + const repo = { root: "/unused", repoKey: "aaaaaaaaaaaa", name: "repo" }; + const empty = () => ({ deliveries: [], warnings: [] }); + const firstReq = request({ accept: "text/event-stream" }); + const firstRes = new FakeResponse(); + serveOvenEventFeed({ + req: firstReq, + res: firstRes, + url: new URL("http://localhost/api/events?stream=1"), + repos: [repo], + json() {}, + maxSubscribers: 1, + timers: fakeTimers(), + readDeliveries: empty, + }); + assert.throws(() => serveOvenEventFeed({ + req: request({ accept: "text/event-stream" }), + res: new FakeResponse(), + url: new URL("http://localhost/api/events?stream=1"), + repos: [repo], + json() {}, + maxSubscribers: 1, + timers: fakeTimers(), + readDeliveries: empty, + }), (error) => error.status === 429); + firstReq.emit("aborted"); + + const failed = new FakeResponse([true, new Error("socket closed")]); + assert.doesNotThrow(() => serveOvenEventFeed({ + req: request({ accept: "text/event-stream" }), + res: failed, + url: new URL("http://localhost/api/events?stream=1"), + repos: [repo], + json() {}, + maxSubscribers: 1, + timers: fakeTimers(), + readDeliveries: () => ({ deliveries: [delivery(repo, "first", 1)], warnings: [] }), + })); + assert.equal(failed.destroyed, true); + + const replacement = new FakeResponse(); + assert.doesNotThrow(() => serveOvenEventFeed({ + req: request({ accept: "text/event-stream" }), + res: replacement, + url: new URL("http://localhost/api/events?stream=1"), + repos: [repo], + json() {}, + maxSubscribers: 1, + timers: fakeTimers(), + readDeliveries: empty, + })); + replacement.emit("close"); +}); + +test("SSE pauses scans under backpressure and reports isolated observer errors", () => { + const repo = { root: "/unused", repoKey: "aaaaaaaaaaaa", name: "repo" }; + const timers = fakeTimers(); + const res = new FakeResponse([true, true, false, true]); + let reads = 0; + const batches = [ + { + deliveries: [delivery(repo, "first", 1)], + warnings: [{ repoKey: repo.repoKey, code: "ECORRUPT", error: "bad neighboring stream" }], + }, + { deliveries: [delivery(repo, "second", 2)], warnings: [] }, + ]; + const req = request({ accept: "text/event-stream" }); + serveOvenEventFeed({ + req, + res, + url: new URL("http://localhost/api/events?stream=1"), + repos: [repo], + json() {}, + timers, + readDeliveries() { return batches[Math.min(reads++, batches.length - 1)]; }, + }); + assert.equal(reads, 1); + assert.match(res.writes.join(""), /event: observer-error/u); + assert.match(res.writes.join(""), /event: oven-event/u); + timers.handles[0].callback(); + timers.handles[0].callback(); + assert.equal(reads, 1); + res.emit("drain"); + timers.handles[0].callback(); + assert.equal(reads, 2); + assert.equal(res.writes.filter((value) => value.includes("event: oven-event")).length, 2); + req.emit("aborted"); + assert.ok(timers.handles.every((handle) => handle.cleared)); +}); diff --git a/src/events/oven-event-store.mjs b/src/events/oven-event-store.mjs new file mode 100644 index 0000000..218d79b --- /dev/null +++ b/src/events/oven-event-store.mjs @@ -0,0 +1,365 @@ +import { randomBytes } from "node:crypto"; +import { + closeSync, + constants, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + opendirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { ovenId } from "../ovens/oven-contract.mjs"; +import { containedJoin, withRepoStateLock } from "../server/repo-state.mjs"; +import { + assertOvenEvent, + normalizeOvenEvent, + OVEN_EVENT_MAX_BYTES, + serializeOvenEvent, +} from "./oven-event-contract.mjs"; + +const eventIndexPattern = /^(\d{12})\.idx$/u; +const eventRecordPattern = /^(oe1-[a-f0-9]{64})\.json$/u; +const eventIdPattern = /^oe1-[a-f0-9]{64}$/u; +const EVENT_INDEX_BYTES = Buffer.byteLength(`${"oe1-"}${"0".repeat(64)}\n`); +const MAX_SEQUENCE = 999_999_999_999; +export const OVEN_EVENT_MAX_READ_EVENTS = 1_000; +export const OVEN_EVENT_MAX_READ_STREAMS = 64; +export const OVEN_EVENT_MAX_SEQUENCE_SCANS = 4_096; +export const OVEN_EVENT_MAX_DISCOVERY_SCANS = 256; + +function fsyncDirectory(path) { + const descriptor = openSync(path, constants.O_RDONLY); + try { fsyncSync(descriptor); } finally { closeSync(descriptor); } +} + +function atomicWrite(path, contents) { + const temporary = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; + let descriptor; + try { + descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + writeFileSync(descriptor, contents); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + renameSync(temporary, path); + fsyncDirectory(dirname(path)); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + rmSync(temporary, { force: true }); + } +} + +function readEventFile(path) { + const stat = lstatSync(path); + if (!stat.isFile() || stat.size < 2 || stat.size > OVEN_EVENT_MAX_BYTES) { + throw Object.assign(new Error("Oven event file is invalid or too large."), { code: "ECORRUPT" }); + } + const text = readFileSync(path, "utf8"); + let event; + try { event = assertOvenEvent(JSON.parse(text)); } + catch (error) { + throw Object.assign(new Error(`Oven event file is corrupt: ${error.message}`), { code: "ECORRUPT", cause: error }); + } + if (text !== serializeOvenEvent(event)) { + throw Object.assign(new Error("Oven event file is not canonical."), { code: "ECORRUPT" }); + } + return event; +} + +export function ovenEventsDir(repoRoot) { + return containedJoin(repoRoot, "events"); +} + +function counterPath(repoRoot, id) { + return containedJoin(repoRoot, "events", id, "sequence.txt"); +} + +function parseCounter(value, id) { + if (!/^\d{1,12}\n$/u.test(value) || Number(value) > MAX_SEQUENCE) { + throw Object.assign(new Error(`Oven ${id} event sequence is corrupt.`), { code: "ECORRUPT" }); + } + return Number(value); +} + +function readCounter(repoRoot, id) { + const path = counterPath(repoRoot, id); + const stat = lstatSync(path); + if (!stat.isFile() || stat.size < 2 || stat.size > 13) { + throw Object.assign(new Error(`Oven ${id} event sequence is corrupt.`), { code: "ECORRUPT" }); + } + return readFileSync(path, "utf8"); +} + +function openDirectoryIfPresent(path) { + try { return opendirSync(path); } + catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +function recoveryScanLimitError(id) { + return Object.assign( + new Error(`Oven ${id} sequence recovery exceeded its ${OVEN_EVENT_MAX_SEQUENCE_SCANS} scan limit; restore sequence.txt.`), + { code: "ESCANLIMIT" }, + ); +} + +function recoverHighestSequence(repoRoot, id) { + let highest = 0; + const sequenceDir = containedJoin(repoRoot, "events", id, "sequence"); + const recordsDir = containedJoin(repoRoot, "events", id, "records"); + { + const directory = openDirectoryIfPresent(sequenceDir); + if (directory) { + let scanned = 0; + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + scanned += 1; + if (scanned > OVEN_EVENT_MAX_SEQUENCE_SCANS) throw recoveryScanLimitError(id); + const match = entry.isFile() ? entry.name.match(eventIndexPattern) : null; + if (match) highest = Math.max(highest, Number(match[1])); + } + } finally { directory.closeSync(); } + } + } + { + const directory = openDirectoryIfPresent(recordsDir); + if (directory) { + let scanned = 0; + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + scanned += 1; + if (scanned > OVEN_EVENT_MAX_SEQUENCE_SCANS) throw recoveryScanLimitError(id); + if (!entry.isFile() || !eventRecordPattern.test(entry.name)) continue; + const path = containedJoin(repoRoot, "events", id, "records", entry.name); + try { + const event = readEventFile(path); + if (event.ovenId === id && `${event.eventId}.json` === entry.name && event.sequence <= MAX_SEQUENCE) { + highest = Math.max(highest, event.sequence); + } + } catch (error) { + if (!["ECORRUPT", "ENOENT"].includes(error?.code)) throw error; + // Corrupt records do not prevent recovery from valid reservations. + } + } + } finally { directory.closeSync(); } + } + } + atomicWrite(counterPath(repoRoot, id), `${highest}\n`); + return highest; +} + +function currentSequence(repoRoot, id) { + try { return parseCounter(readCounter(repoRoot, id), id); } + catch (error) { + if (["ECORRUPT", "ENOENT"].includes(error?.code)) return recoverHighestSequence(repoRoot, id); + throw error; + } +} + +function readHighestSequence(repoRoot, id) { + return parseCounter(readCounter(repoRoot, id), id); +} + +function eventPaths(repoRoot, event) { + const sequence = String(event.sequence).padStart(12, "0"); + return { + record: containedJoin(repoRoot, "events", event.ovenId, "records", `${event.eventId}.json`), + index: containedJoin(repoRoot, "events", event.ovenId, "sequence", `${sequence}.idx`), + }; +} + +function ensureIndex(path, eventId) { + const contents = `${eventId}\n`; + if (!existsSync(path)) atomicWrite(path, contents); + else { + const stat = lstatSync(path); + if (!stat.isFile() || stat.size !== Buffer.byteLength(contents) || readFileSync(path, "utf8") !== contents) { + throw new Error(`Oven event index is invalid: ${path}`); + } + } +} + +export function publishOvenEvent(repoRoot, input, options = {}) { + const draft = normalizeOvenEvent(input, options); + return withRepoStateLock(repoRoot, () => { + const recordPath = containedJoin(repoRoot, "events", draft.ovenId, "records", `${draft.eventId}.json`); + mkdirSync(dirname(recordPath), { recursive: true }); + mkdirSync(containedJoin(repoRoot, "events", draft.ovenId, "sequence"), { recursive: true }); + if (existsSync(recordPath)) { + const event = readEventFile(recordPath); + if (event.eventId !== draft.eventId || event.ovenId !== draft.ovenId) { + throw Object.assign(new Error("Oven event record does not match its deterministic filename."), { code: "ECORRUPT" }); + } + const paths = eventPaths(repoRoot, event); + ensureIndex(paths.index, event.eventId); + if (currentSequence(repoRoot, draft.ovenId) < event.sequence) recoverHighestSequence(repoRoot, draft.ovenId); + return { event, created: false, path: paths.record }; + } + const sequence = currentSequence(repoRoot, draft.ovenId) + 1; + if (sequence > MAX_SEQUENCE) throw new Error(`Oven ${draft.ovenId} event sequence is exhausted.`); + const event = assertOvenEvent({ ...draft, sequence }); + const serialized = serializeOvenEvent(event); + atomicWrite(counterPath(repoRoot, draft.ovenId), `${sequence}\n`); + const paths = eventPaths(repoRoot, event); + atomicWrite(paths.record, serialized); + ensureIndex(paths.index, event.eventId); + return { event, created: true, path: paths.record }; + }); +} + +function normalizedAfterSequences(afterSequences) { + if (!afterSequences || typeof afterSequences !== "object" || Array.isArray(afterSequences)) { + throw new Error("Oven event afterSequences must be an object."); + } + const entries = Object.entries(afterSequences); + if (entries.length > OVEN_EVENT_MAX_READ_STREAMS) { + throw new Error(`Oven event afterSequences is limited to ${OVEN_EVENT_MAX_READ_STREAMS} streams.`); + } + return Object.fromEntries(entries.map(([id, sequence]) => { + const normalizedId = ovenId(id); + if (!Number.isSafeInteger(sequence) || sequence < 0 || sequence > MAX_SEQUENCE) { + throw new Error(`Oven ${normalizedId} replay sequence is invalid.`); + } + return [normalizedId, sequence]; + })); +} + +function streamLimitError(message) { + return Object.assign(new Error(message), { code: "ESTREAMLIMIT" }); +} + +function eventDirectories(repoRoot, ovenIds, maxStreams) { + if (!Number.isSafeInteger(maxStreams) || maxStreams < 1 || maxStreams > OVEN_EVENT_MAX_READ_STREAMS) { + throw new Error(`Oven event stream limit must be from 1 to ${OVEN_EVENT_MAX_READ_STREAMS}.`); + } + if (ovenIds !== undefined && !Array.isArray(ovenIds)) throw new Error("Oven event ovenIds must be an array."); + if (ovenIds?.length) { + const ids = [...new Set(ovenIds.map((id) => ovenId(id)))].sort(); + if (ids.length > maxStreams) throw streamLimitError(`Oven event reads are limited to ${maxStreams} streams.`); + return ids.map((id) => ({ id, path: containedJoin(repoRoot, "events", id, "sequence") })); + } + const root = ovenEventsDir(repoRoot); + if (!existsSync(root)) return []; + const directory = opendirSync(root); + const streams = []; + let inspected = 0; + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + inspected += 1; + if (inspected > OVEN_EVENT_MAX_DISCOVERY_SCANS) { + throw streamLimitError(`Oven event discovery is limited to ${OVEN_EVENT_MAX_DISCOVERY_SCANS} entries; filter by ovenId.`); + } + if (!entry.isDirectory()) continue; + try { streams.push({ id: ovenId(entry.name), path: containedJoin(repoRoot, "events", entry.name, "sequence") }); } + catch { /* Ignore unrelated local-state directories. */ } + if (streams.length > maxStreams) throw streamLimitError(`Oven event reads are limited to ${maxStreams} streams; filter by ovenId.`); + } + } finally { directory.closeSync(); } + return streams.sort((left, right) => left.id.localeCompare(right.id)); +} + +export function openOvenEventStreams(repoRoot, { + ovenIds, + afterSequences = {}, + maxStreams = OVEN_EVENT_MAX_READ_STREAMS, + maxSequenceScans = OVEN_EVENT_MAX_SEQUENCE_SCANS, + onInvalid = () => {}, +} = {}) { + if (typeof onInvalid !== "function") throw new Error("Oven event onInvalid must be a function."); + if (!Number.isSafeInteger(maxSequenceScans) || maxSequenceScans < 1 || maxSequenceScans > OVEN_EVENT_MAX_SEQUENCE_SCANS) { + throw new Error(`Oven event sequence scan limit must be from 1 to ${OVEN_EVENT_MAX_SEQUENCE_SCANS}.`); + } + const after = normalizedAfterSequences(afterSequences); + return eventDirectories(repoRoot, ovenIds, maxStreams).flatMap((stream) => { + if (!existsSync(stream.path)) return []; + let highest; + try { highest = readHighestSequence(repoRoot, stream.id); } + catch (error) { onInvalid(error, counterPath(repoRoot, stream.id)); return []; } + let sequence = (after[stream.id] ?? 0) + 1; + let scans = 0; + // A corrupt or non-canonical record fail-closes the TAIL of this Oven's stream + // (isolated to this one Oven): once blocked, next() stops at that sequence rather + // than skipping the record and continuing, preserving per-Oven ordering. + let blocked = false; + return [{ + ovenId: stream.id, + next() { + if (blocked) return null; + while (sequence <= highest && scans < maxSequenceScans) { + const current = sequence; + sequence += 1; + scans += 1; + const indexPath = containedJoin(repoRoot, "events", stream.id, "sequence", `${String(current).padStart(12, "0")}.idx`); + let stat; + try { stat = lstatSync(indexPath); } + catch (error) { + if (error?.code === "ENOENT") continue; + blocked = true; + onInvalid(error, indexPath); + return null; + } + try { + if (!stat.isFile() || stat.size !== EVENT_INDEX_BYTES) throw new Error("Oven event index is invalid."); + const indexText = readFileSync(indexPath, "utf8"); + const indexedId = indexText.slice(0, -1); + if (indexText !== `${indexedId}\n` || !eventIdPattern.test(indexedId)) throw new Error("Oven event index is invalid."); + const path = containedJoin(repoRoot, "events", stream.id, "records", `${indexedId}.json`); + const event = readEventFile(path); + if (event.ovenId !== stream.id || event.sequence !== current || event.eventId !== indexedId) { + throw new Error("Oven event index does not match its record."); + } + return event; + } catch (error) { + blocked = true; + onInvalid(error, indexPath); + return null; + } + } + if (sequence <= highest) { + blocked = true; + const error = new Error(`Oven ${stream.id} replay exceeded its ${maxSequenceScans} sequence scan limit.`); + error.code = "ESCANLIMIT"; + onInvalid(error, stream.path); + } + return null; + }, + }]; + }); +} + +export function readOvenEvents(repoRoot, { + ovenIds, + afterSequences = {}, + limit = OVEN_EVENT_MAX_READ_EVENTS, + limitPerOven = OVEN_EVENT_MAX_READ_EVENTS, + maxStreams = OVEN_EVENT_MAX_READ_STREAMS, + maxSequenceScans = OVEN_EVENT_MAX_SEQUENCE_SCANS, + onInvalid = () => {}, +} = {}) { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > OVEN_EVENT_MAX_READ_EVENTS) { + throw new Error(`Oven event limit must be from 1 to ${OVEN_EVENT_MAX_READ_EVENTS}.`); + } + if (!Number.isSafeInteger(limitPerOven) || limitPerOven < 1 || limitPerOven > OVEN_EVENT_MAX_READ_EVENTS) { + throw new Error(`Oven event per-Oven limit must be from 1 to ${OVEN_EVENT_MAX_READ_EVENTS}.`); + } + const events = []; + for (const stream of openOvenEventStreams(repoRoot, { ovenIds, afterSequences, maxStreams, maxSequenceScans, onInvalid })) { + let accepted = 0; + while (accepted < limitPerOven && events.length < limit) { + const event = stream.next(); + if (!event) break; + events.push(event); + accepted += 1; + } + if (events.length >= limit) break; + } + return events; +} diff --git a/src/events/oven-events.mjs b/src/events/oven-events.mjs new file mode 100644 index 0000000..ea414a5 --- /dev/null +++ b/src/events/oven-events.mjs @@ -0,0 +1,17 @@ +export { + OVEN_EVENT_AUTHORITY, + OVEN_EVENT_MAX_BYTES, + OVEN_EVENT_SCHEMA, + assertOvenEvent, + normalizeOvenEvent, + ovenEventId, +} from "./oven-event-contract.mjs"; +export { + OVEN_EVENT_MAX_DISCOVERY_SCANS, + OVEN_EVENT_MAX_READ_EVENTS, + OVEN_EVENT_MAX_READ_STREAMS, + OVEN_EVENT_MAX_SEQUENCE_SCANS, + ovenEventsDir, + publishOvenEvent, + readOvenEvents, +} from "./oven-event-store.mjs"; diff --git a/src/events/oven-events.test.mjs b/src/events/oven-events.test.mjs new file mode 100644 index 0000000..409ba05 --- /dev/null +++ b/src/events/oven-events.test.mjs @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { + assertOvenEvent, + normalizeOvenEvent, + OVEN_EVENT_MAX_DISCOVERY_SCANS, + OVEN_EVENT_MAX_BYTES, + OVEN_EVENT_MAX_SEQUENCE_SCANS, + publishOvenEvent, + readOvenEvents, +} from "./oven-events.mjs"; +import { serializeOvenEvent } from "./oven-event-contract.mjs"; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "burnlist-oven-events-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} + +function input(overrides = {}) { + return { + ovenId: "future-oven", + subjectId: "subject-1", + kind: "iteration", + phase: "complete", + cursor: "run-1", + occurredAt: "2026-07-21T12:00:00.000Z", + payload: { result: "advanced", count: 1 }, + ...overrides, + }; +} + +test("Oven event identity is deterministic while occurrence and payload remain observational", () => { + const first = normalizeOvenEvent(input()); + const later = normalizeOvenEvent(input({ occurredAt: "2026-07-21T12:01:00.000Z", payload: { result: "changed" } })); + assert.equal(first.eventId, later.eventId); + assert.equal(assertOvenEvent({ ...first, sequence: 1 }).authority, "observational"); + assert.throws(() => assertOvenEvent({ ...first, sequence: 1, cursor: "different" }), /canonical identity/u); + assert.throws(() => normalizeOvenEvent({ ...input(), extra: true }), /unsupported field/u); + assert.throws(() => normalizeOvenEvent(input({ payload: { value: Number.NaN } })), /non-finite/u); +}); + +test("Oven event store publishes atomically and keeps the first copy on retry", (t) => { + const repo = fixture(t); + const first = publishOvenEvent(repo, input()); + const retry = publishOvenEvent(repo, input({ occurredAt: "2026-07-21T12:05:00.000Z", payload: { result: "retry" } })); + assert.equal(first.created, true); + assert.equal(retry.created, false); + assert.deepEqual(retry.event, first.event); + assert.equal(readFileSync(first.path, "utf8"), `${JSON.stringify(first.event)}\n`); + assert.deepEqual(readOvenEvents(repo), [first.event]); +}); + +test("Oven event retry repairs an interrupted record-before-index publication", (t) => { + const repo = fixture(t); + const first = publishOvenEvent(repo, input()); + const eventRoot = dirname(dirname(first.path)); + const index = join( + eventRoot, + "sequence", + `${String(first.event.sequence).padStart(12, "0")}.idx`, + ); + rmSync(index); + assert.deepEqual(readOvenEvents(repo), []); + + const retry = publishOvenEvent(repo, input({ payload: { result: "retry" } })); + assert.equal(retry.created, false); + assert.deepEqual(retry.event, first.event); + assert.deepEqual(readOvenEvents(repo), [first.event]); +}); + +test("Oven event reader filters Ovens and ignores corrupt or noncanonical files", (t) => { + const repo = fixture(t); + const first = publishOvenEvent(repo, input()); + const second = publishOvenEvent(repo, input({ ovenId: "other-oven", cursor: "run-2", occurredAt: "2026-07-21T12:01:00.000Z" })); + const eventRoot = dirname(dirname(first.path)); + const brokenId = `oe1-${"f".repeat(64)}`; + const broken = join(eventRoot, "records", `${brokenId}.json`); + writeFileSync(broken, "{"); + writeFileSync(join(eventRoot, "sequence", "000000000002.idx"), `${brokenId}\n`); + writeFileSync(join(eventRoot, "sequence.txt"), "2\n"); + const warnings = []; + assert.deepEqual(readOvenEvents(repo, { ovenIds: ["other-oven"], onInvalid: (error) => warnings.push(error.message) }), [second.event]); + assert.equal(warnings.length, 0); + assert.deepEqual(readOvenEvents(repo, { onInvalid: (error) => warnings.push(error.message) }), [first.event, second.event]); + assert.equal(warnings.length, 1); +}); + +test("Oven event reader limits preserve the oldest unread sequence", (t) => { + const repo = fixture(t); + const first = publishOvenEvent(repo, input({ cursor: "run-1" })); + const second = publishOvenEvent(repo, input({ cursor: "run-2" })); + const other = publishOvenEvent(repo, input({ ovenId: "other-oven", cursor: "other-run" })); + assert.deepEqual(readOvenEvents(repo, { limit: 1 }), [first.event]); + assert.deepEqual(readOvenEvents(repo, { limitPerOven: 1 }), [first.event, other.event]); + assert.deepEqual(readOvenEvents(repo, { ovenIds: [] }), [first.event, second.event, other.event]); + assert.throws(() => readOvenEvents(repo, { limit: 1_001 }), /from 1 to 1000/u); +}); + +test("Oven event size validation includes the exact newline written to disk", (t) => { + const repo = fixture(t); + const large = Object.fromEntries(Array.from({ length: 7 }, (_, index) => [`field${index}`, "x".repeat(4_096)])); + const draft = normalizeOvenEvent(input({ payload: { ...large, tail: "" } })); + const baseBytes = Buffer.byteLength(serializeOvenEvent({ ...draft, sequence: 1 })); + const tailLength = OVEN_EVENT_MAX_BYTES - baseBytes; + assert.ok(tailLength > 0 && tailLength <= 4_096); + const exact = publishOvenEvent(repo, input({ payload: { ...large, tail: "x".repeat(tailLength) } })); + assert.equal(statSync(exact.path).size, OVEN_EVENT_MAX_BYTES); + assert.deepEqual(readOvenEvents(repo), [exact.event]); + + const counter = join(dirname(dirname(exact.path)), "sequence.txt"); + assert.throws( + () => publishOvenEvent(repo, input({ cursor: "too-large", payload: { ...large, tail: "x".repeat(tailLength + 1) } })), + /larger than 32768 bytes after sequencing/u, + ); + assert.equal(readFileSync(counter, "utf8"), "1\n"); +}); + +test("Oven event sequence recovery ignores corrupt records but propagates containment failures", (t) => { + const root = fixture(t); + const repo = join(root, "repo"); + const outside = join(root, "outside-counter"); + mkdirSync(repo); + writeFileSync(outside, "1\n"); + const first = publishOvenEvent(repo, input({ cursor: "run-1" })); + const eventRoot = dirname(dirname(first.path)); + rmSync(join(eventRoot, "sequence.txt")); + writeFileSync(join(eventRoot, "records", `oe1-${"e".repeat(64)}.json`), "{"); + const second = publishOvenEvent(repo, input({ cursor: "run-2" })); + assert.equal(second.event.sequence, 2); + + rmSync(join(eventRoot, "sequence.txt")); + symlinkSync(outside, join(eventRoot, "sequence.txt")); + assert.throws(() => publishOvenEvent(repo, input({ cursor: "run-3" })), /escapes/u); + rmSync(join(eventRoot, "sequence.txt")); + writeFileSync(join(eventRoot, "sequence.txt"), "2\n"); + assert.deepEqual(readOvenEvents(repo).map((event) => event.sequence), [1, 2]); +}); + +test("Oven event sequence recovery bounds record-directory scans", (t) => { + const repo = fixture(t); + const first = publishOvenEvent(repo, input()); + const eventRoot = dirname(dirname(first.path)); + const recordsDir = join(eventRoot, "records"); + rmSync(join(eventRoot, "sequence.txt")); + for (let index = 0; index <= OVEN_EVENT_MAX_SEQUENCE_SCANS; index += 1) { + writeFileSync(join(recordsDir, `pad-${index}.txt`), "x"); + } + assert.throws( + () => publishOvenEvent(repo, input({ cursor: "after" })), + /scan limit|recovery exceeded/u, + ); + assert.equal(existsSync(join(eventRoot, "sequence.txt")), false); +}); + +test("Oven event counter-only crash gaps remain consumed reservations", (t) => { + const repo = fixture(t); + const first = publishOvenEvent(repo, input({ cursor: "run-1" })); + const eventRoot = dirname(dirname(first.path)); + writeFileSync(join(eventRoot, "sequence.txt"), "3\n"); + const next = publishOvenEvent(repo, input({ cursor: "run-after-crash" })); + assert.equal(next.event.sequence, 4); + assert.deepEqual(readOvenEvents(repo).map((event) => event.sequence), [1, 4]); +}); + +test("Oven event reads bound cursor and stream-directory discovery work", (t) => { + const repo = fixture(t); + const eventsRoot = join(repo, ".local", "burnlist", "events"); + mkdirSync(eventsRoot, { recursive: true }); + for (let index = 0; index <= OVEN_EVENT_MAX_DISCOVERY_SCANS; index += 1) { + writeFileSync(join(eventsRoot, `unrelated-${index}`), "x"); + } + assert.throws(() => readOvenEvents(repo), /discovery is limited/u); + const afterSequences = Object.fromEntries(Array.from({ length: 65 }, (_, index) => [`oven-${index}`, 0])); + assert.throws(() => readOvenEvents(fixture(t), { afterSequences }), /afterSequences is limited to 64 streams/u); +}); + +test("Oven event corruption blocks only its own stream before later sequences", (t) => { + const repo = fixture(t); + const first = publishOvenEvent(repo, input({ cursor: "run-1" })); + publishOvenEvent(repo, input({ cursor: "run-2" })); + const healthy = publishOvenEvent(repo, input({ ovenId: "other-oven", cursor: "healthy" })); + writeFileSync(first.path, "{"); + const warnings = []; + assert.deepEqual(readOvenEvents(repo, { onInvalid: (error) => warnings.push(error.message) }), [healthy.event]); + assert.equal(warnings.length, 1); +}); + +test("Oven event store rejects repo-state symlinks that escape the repository", (t) => { + const root = fixture(t); + const repo = join(root, "repo"); + const outside = join(root, "outside"); + mkdirSync(repo); + mkdirSync(outside); + mkdirSync(join(repo, ".local")); + symlinkSync(outside, join(repo, ".local", "burnlist"), "dir"); + assert.throws(() => publishOvenEvent(repo, input()), /escapes/u); +}); + +test("Oven event store assigns unique monotonic sequences to concurrent producers", async (t) => { + const repo = fixture(t); + const moduleUrl = new URL("./oven-event-store.mjs", import.meta.url).href; + const script = [ + `import { publishOvenEvent } from ${JSON.stringify(moduleUrl)};`, + "publishOvenEvent(process.argv[1], {", + " ovenId: 'future-oven', subjectId: 'subject-1', kind: 'iteration', phase: 'complete',", + " cursor: process.argv[2], occurredAt: '2026-07-21T12:00:00.000Z', payload: {},", + "});", + ].join("\n"); + const children = Array.from({ length: 8 }, (_, index) => spawn( + process.execPath, + ["--input-type=module", "--eval", script, repo, `run-${index}`], + { stdio: ["ignore", "ignore", "pipe"] }, + )); + const results = await Promise.all(children.map((child) => new Promise((resolve) => { + let error = ""; + child.stderr.on("data", (chunk) => { error += chunk; }); + child.once("close", (status) => resolve({ status, error })); + }))); + assert.deepEqual(results.map((result) => result.status), Array(8).fill(0), results.map((result) => result.error).join("\n")); + const events = readOvenEvents(repo); + assert.equal(events.length, 8); + assert.deepEqual(events.map((event) => event.sequence).sort((a, b) => a - b), [1, 2, 3, 4, 5, 6, 7, 8]); +}); diff --git a/src/server/burnlist-dashboard-server.mjs b/src/server/burnlist-dashboard-server.mjs index 887cac1..23bcaae 100644 --- a/src/server/burnlist-dashboard-server.mjs +++ b/src/server/burnlist-dashboard-server.mjs @@ -38,6 +38,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 { discoverBurnlistSummaries } from "./burnlist-discovery.mjs"; +import { serveOvenEventFeed } from "../events/oven-event-feed.mjs"; import { isolatedDashboardEntries } from "./dashboard-entry-isolation.mjs"; import { atomicDirectory, atomicOvenPackage, readTextFileWithLimit, resolveOvenPackageDir, safeStat, withOvenPackageLock } from "./fs-safe.mjs"; import { @@ -1144,6 +1145,11 @@ const server = createServer(async (req, res) => { } return; } + if (url.pathname === "/api/events") { + if (method !== "GET") return json(res, 405, { error: "method not allowed" }); + serveOvenEventFeed({ req, res, url, repos: ovenScopeRepos(), json }); + return; + } if (url.pathname === "/api/ovens") { if (method === "GET") { json(res, 200, { ovens: discoverOvens().map(ovenSummary), writeToken }); diff --git a/src/server/oven-event-routes.test.mjs b/src/server/oven-event-routes.test.mjs new file mode 100644 index 0000000..b996581 --- /dev/null +++ b/src/server/oven-event-routes.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { request } from "node:http"; +import { realpathSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { publishOvenEvent } from "../events/oven-event-store.mjs"; +import { repoKey } from "./registry.mjs"; +import { httpGet, withServer } from "./dashboard-routes-fixtures.mjs"; + +function event(cursor, occurredAt) { + return { + ovenId: "future-oven", + subjectId: "subject-1", + kind: "iteration", + phase: "complete", + cursor, + occurredAt, + payload: { cursor }, + }; +} + +function nextSseEvent(baseUrl, path, afterOpen) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for Oven event SSE")), 5_000); + const req = request(new URL(path, baseUrl), { headers: { accept: "text/event-stream" } }); + req.once("response", (res) => { + let buffer = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + buffer += chunk; + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() ?? ""; + for (const block of blocks) { + if (!block.includes("event: oven-event")) continue; + const data = block.split("\n").find((line) => line.startsWith("data: "))?.slice(6); + if (!data) continue; + clearTimeout(timer); + req.destroy(); + resolve(JSON.parse(data)); + return; + } + }); + afterOpen(); + }); + req.once("error", (error) => { + if (error.code !== "ECONNRESET") reject(error); + }); + req.end(); + }); +} + +test("/api/events returns filtered replay with stable delivery cursors", { timeout: 20_000 }, async () => { + await withServer({ withBurnlist: true }, async ({ baseUrl, repoRoot }) => { + const first = publishOvenEvent(repoRoot, event("run-1", "2026-07-21T12:00:00.000Z")); + const second = publishOvenEvent(repoRoot, event("run-2", "2026-07-21T12:01:00.000Z")); + const key = repoKey(repoRoot); + const firstPage = await httpGet(baseUrl, `/api/events?repoKey=${key}&ovenId=future-oven&limit=1`); + assert.equal(firstPage.status, 200); + const firstPayload = JSON.parse(firstPage.body); + assert.equal(firstPayload.events[0].eventId, first.event.eventId); + assert.equal(firstPayload.truncated, true); + const response = await httpGet(baseUrl, `/api/events?repoKey=${key}&ovenId=future-oven&after=${encodeURIComponent(firstPayload.cursor)}`); + assert.equal(response.status, 200); + const payload = JSON.parse(response.body); + assert.equal(payload.schema, "burnlist-oven-event-feed@1"); + assert.equal(payload.total, 1); + assert.equal(payload.events[0].eventId, second.event.eventId); + assert.equal(payload.events[0].deliveryId, `${key}:future-oven:2:${second.event.eventId}`); + assert.equal(Object.hasOwn(payload.events[0], "repoRoot"), false); + }); +}); + +test("/api/events never advances one Oven past an earlier sequence when producer clocks regress", { timeout: 20_000 }, async () => { + await withServer({ withBurnlist: true }, async ({ baseUrl, repoRoot }) => { + const first = publishOvenEvent(repoRoot, event("sequence-1", "2026-07-21T12:00:00.000Z")); + publishOvenEvent(repoRoot, event("sequence-2", "2026-07-21T11:00:00.000Z")); + const page = JSON.parse((await httpGet(baseUrl, "/api/events?ovenId=future-oven&limit=1")).body); + assert.equal(page.events[0].eventId, first.event.eventId); + const replay = JSON.parse((await httpGet(baseUrl, `/api/events?ovenId=future-oven&after=${encodeURIComponent(page.cursor)}`)).body); + assert.equal(replay.events[0].cursor, "sequence-2"); + }); +}); + +test("/api/events streams a newly published event without an agent heartbeat", { timeout: 20_000 }, async () => { + await withServer({ withBurnlist: true }, async ({ baseUrl, repoRoot }) => { + const key = repoKey(repoRoot); + const received = await nextSseEvent(baseUrl, `/api/events?stream=1&repoKey=${key}`, () => { + setTimeout(() => publishOvenEvent(repoRoot, event("live-run", new Date().toISOString())), 50); + }); + assert.equal(received.cursor, "live-run"); + assert.equal(received.repoKey, key); + }); +}); + +test("/api/events rejects malformed replay cursors and unknown project keys", { timeout: 20_000 }, async () => { + await withServer({ withBurnlist: true }, async ({ baseUrl }) => { + const unknownRepo = await httpGet(baseUrl, `/api/events?repoKey=${"f".repeat(12)}`); + assert.equal(unknownRepo.status, 404); + const malformedCursor = await httpGet(baseUrl, "/api/events?after=not-a-cursor"); + assert.equal(malformedCursor.status, 400); + }); +}); + +test("/api/events vector cursor replays a later cross-repo write with an earlier producer time", { timeout: 20_000 }, async () => { + let repoA; + let repoB; + await withServer({ + burnlists: [{ repoPath: "repo-a", id: "first" }, { repoPath: "repo-b", id: "second" }], + setup({ fixtureRoot }) { + repoA = realpathSync(join(fixtureRoot, "repo-a")); + repoB = realpathSync(join(fixtureRoot, "repo-b")); + publishOvenEvent(repoA, event("repo-a-run", "2026-07-21T12:00:00.000Z")); + }, + }, async ({ baseUrl }) => { + const firstPage = JSON.parse((await httpGet(baseUrl, "/api/events?limit=1")).body); + assert.equal(firstPage.events[0].repoKey, repoKey(repoA)); + publishOvenEvent(repoB, event("repo-b-late-write", "2026-07-21T11:00:00.000Z")); + const replay = JSON.parse((await httpGet(baseUrl, `/api/events?after=${encodeURIComponent(firstPage.cursor)}`)).body); + assert.equal(replay.events.length, 1); + assert.equal(replay.events[0].cursor, "repo-b-late-write"); + assert.equal(replay.events[0].repoKey, repoKey(repoB)); + }); +});