Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit acd83ceb04369765701f07551d23d2de87643e1b
parent b36ccebb8811ae775586d7839203b33ba27773bc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 20:53:30 -0400

Merge r13/lows-editor (release 13 slice W1, brought to main 2026-09-30) — the editor lows: Diagnostics cards keep their retry log when a retry empties the bucket; the transcript-source flake; /sites' built-when refreshes when a homepage build ends; /jobs labels for the hub and homepage kinds; a queued cancel writes its final sidecar status; doctor's worker engine binary from paths; an editor test script; reviewed SHIP on 2026-09-28, the merge reviewed SHIP today

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/bin/doctor.test.ts | 30++++++++++++++++++++++++++++++
Mcommon/bin/doctor.ts | 7++++++-
Mcommon/jobs/jobKinds.test.ts | 12++++++++++++
Mcommon/jobs/jobKinds.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/registry.test.ts | 21+++++++++++++++++++++
Mcommon/jobs/registry.ts | 46+++++++++++++++++++++++++++++++++++++++-------
Mcommon/jobs/shutdownCancel.ts | 6++++++
Acommon/jobs/streamCommand.test.ts | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/streamCommand.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++------
Meditor/CHANGELOG.md | 4++++
Meditor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx | 35+++++++++++++++++++++++++++++++----
Meditor/app/sites/components/HomepageBuildButtons.tsx | 16+++++++++++++++-
Meditor/app/sites/components/JobLane.tsx | 54++++++++++++++++++++++++++++++++++++++----------------
Aeditor/e2e/diagnostics-retry-log.spec.ts | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/sites-homepage.spec.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Meditor/e2e/transcript-source.spec.ts | 37+++++++++++++++++++++++++++++++++----
Meditor/package.json | 1+
Mplans/FACTS.md | 15++++++++++++++-
Mplans/release-13.md | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 1131 insertions(+), 48 deletions(-)

diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts @@ -61,7 +61,9 @@ function checkout(): { root: string; bin: string; paths: Paths } { udisksctlBin: "udisksctl", claudeBin: "claude", diarizeBin: path.join(root, "scripts", "diarize.mjs"), + whisperBin: "whisper-cli", whisperModel: path.join(root, "models", "ggml-base.en.bin"), + parakeetBin: path.join(root, "scripts", "parakeet-stitch.mjs"), parakeetModel: "", parakeetCliBin: "parakeet-cli", sourceScrubFile: path.join(root, ".config", "source-scrub.txt"), @@ -189,6 +191,34 @@ test("an enabled whisper worker needs its engine and its model — beside a corp assert.deepEqual(tree(c.root), before); }); +test("a worker with no bin override is checked against the report's Paths, not the process's", async () => { + // release 11 review L7: the default engine came from app.defaultBin(), i.e. + // THIS process's getPaths() — "whisper-cli" here — whatever Paths the doctor + // was handed. Now it is the handed Paths' whisperBin / parakeetBin, as the + // model beside it always was. + const c = checkout(); + const p = c.paths as { whisperBin: string; parakeetBin: string }; + p.whisperBin = "whisper-from-paths"; + p.parakeetBin = "parakeet-from-paths"; + writeFileSync( + c.paths.settingsFile, + JSON.stringify({ + workers: [ + { id: "w1", name: "CPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} }, + { id: "w2", name: "GPU", kind: "local", enabled: true, priority: 1, appId: "parakeet", config: {} }, + ], + }), + ); + fake(c.bin, "whisper-from-paths"); + fake(c.bin, "parakeet-from-paths"); + const r = await run(c); + const detail = (id: string) => r.checks.find((x) => x.id === id)?.detail ?? ""; + assert.equal(status(r, "engine:w1"), "ok", renderDoctorReport(r)); + assert.match(detail("engine:w1"), /whisper-from-paths/); + assert.equal(status(r, "engine:w2"), "ok", renderDoctorReport(r)); + assert.match(detail("engine:w2"), /parakeet-from-paths/); +}); + test("an override naming a binary that is not there fails even when nothing needs it", async () => { const c = checkout(); const r = await run(c, { YTDLP_BIN: "/nonexistent/yt-dlp" }); diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts @@ -261,7 +261,12 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor const cfg = w.config ?? {}; const { getTranscriptionApp } = await import("../lib/transcriptionApps"); const app = getTranscriptionApp(w.appId); - const bin = cfg.bin?.trim() || app.defaultBin(); + // The default engine from the Paths this report is about, as the model + // below: `app.defaultBin()` reads the process's own getPaths() (release + // 11 review L7). chough has no Paths field; its default stays the app's. + const defaultBin = + w.appId === "whisper-cpp" ? paths.whisperBin : w.appId === "parakeet" ? paths.parakeetBin : app.defaultBin(); + const bin = cfg.bin?.trim() || defaultBin; // An engine is LOOKED UP, not run: a transcription engine's CLI has no // cheap version flag, and running one to find out is not a doctor's call. const id = `engine:${w.id}`; diff --git a/common/jobs/jobKinds.test.ts b/common/jobs/jobKinds.test.ts @@ -64,6 +64,18 @@ const ADDED_KINDS: Record<string, { label: string; drainable: boolean }> = { // "retry" on one would mean "start a second dispatcher". "auto-digest": { label: "Auto-digest runner", drainable: true }, "auto-backfill": { label: "Auto-backfill runner", drainable: true }, + // The hub's and the homepage's build and deploy (release 13 slice W1): /jobs + // showed them by their raw kinds. One child process each — a cancel stops + // it, there is nothing to drain. + "build-hub": { label: "Build hub", drainable: false }, + "deploy-hub": { label: "Deploy hub", drainable: false }, + "build-deploy-hub": { label: "Build & deploy hub", drainable: false }, + "build-homepage": { label: "Build homepage", drainable: false }, + "deploy-homepage": { label: "Deploy homepage", drainable: false }, + "build-deploy-homepage": { + label: "Build & deploy homepage", + drainable: false, + }, }; test("added kinds carry their pinned label and drainability", () => { diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts @@ -513,6 +513,59 @@ const JOB_KINDS: Record<string, JobKindMeta> = { queueKeyStrategy: "platform", needsMedia: true, }, + // THE HUB'S AND THE HOMEPAGE'S BUILD AND DEPLOY (release 13 slice W1). They + // ran from /sites — the hub since release 7, the homepage since release 11 — + // with no entry here, so /jobs showed their raw machine kinds. The labels + // are the lanes' own titles on /sites (HubBuildButtons, HomepageBuildButtons). + // + // Queue: BUILD_QUEUE for a build, DEPLOY_QUEUE for a deploy or a + // build-and-deploy (the actions' `queueKey`), hence "custom". Neither + // drainable (one child process; stopping it is a cancel) nor replayable (no + // JobSpec: a replayed deploy would not know its preview branch). No + // `needsMedia`: they read the index and the export output, never a + // channel's `data/`, and carry no channelSlug for the guard to check. + "build-hub": { + kind: "build-hub", + label: "Build hub", + drainable: false, + replayable: false, + queueKeyStrategy: "custom", + }, + "deploy-hub": { + kind: "deploy-hub", + label: "Deploy hub", + drainable: false, + replayable: false, + queueKeyStrategy: "custom", + }, + "build-deploy-hub": { + kind: "build-deploy-hub", + label: "Build & deploy hub", + drainable: false, + replayable: false, + queueKeyStrategy: "custom", + }, + "build-homepage": { + kind: "build-homepage", + label: "Build homepage", + drainable: false, + replayable: false, + queueKeyStrategy: "custom", + }, + "deploy-homepage": { + kind: "deploy-homepage", + label: "Deploy homepage", + drainable: false, + replayable: false, + queueKeyStrategy: "custom", + }, + "build-deploy-homepage": { + kind: "build-deploy-homepage", + label: "Build & deploy homepage", + drainable: false, + replayable: false, + queueKeyStrategy: "custom", + }, // Replayable kinds that never had a JOB_KIND_LABELS entry: label omitted so // jobKindLabel() keeps falling back to the raw kind (unchanged behavior). "store-playlist": { diff --git a/common/jobs/registry.test.ts b/common/jobs/registry.test.ts @@ -126,3 +126,24 @@ test("cancelling a queued background job fires onCancel and removes it", () => { "removed from its queue", ); }); + +// Release 13 slice W1: a managed job persists its sidecar in onCancel, so a +// queued job must already be terminal when the scheduler fires it. It used to +// be marked after, and the sidecar kept "queued". +test("a queued job is already cancelled when its onCancel runs", () => { + const q = `test:cancel-order:${newJobId()}`; + enqueue(rec(q)); // the head, running + const queued = rec(q); + let seen: { status: string; endedAt?: number } | null = null; + getRegistry().register(queued); + getRegistry().enqueue(queued, { + start: () => {}, + onCancel: () => { + seen = { status: queued.status, endedAt: queued.endedAt }; + }, + }); + assert.equal(getRegistry().cancel(queued.id), true); + assert.equal(seen!.status, "cancelled"); + assert.equal(typeof seen!.endedAt, "number"); + assert.equal(getRegistry().positionInQueue(queued.id), -1); +}); diff --git a/common/jobs/registry.ts b/common/jobs/registry.ts @@ -166,6 +166,20 @@ export type QueueSnapshot = { type StartFn = () => void; type CancelFn = () => void; +// The terminal transition itself: status, endedAt, exitCode — once. A record +// already terminal keeps the state it ended in (a cancel racing a child's exit +// stays "cancelled"). +function markTerminal( + job: JobRecord, + status: "done" | "failed" | "cancelled", + exitCode?: number, +): void { + if (job.status !== "queued" && job.status !== "running") return; + job.status = status; + job.endedAt = Date.now(); + if (typeof exitCode === "number") job.exitCode = exitCode; +} + // The registry owns job LIFECYCLE/STATE (the JobRecord, meta sidecars, tasks, // terminal transitions). Queue ORDERING — which job runs vs. waits, and the // foreground-before-background priority — is delegated to the shared Scheduler @@ -174,6 +188,14 @@ type CancelFn = () => void; // preserving the long-standing one-running-job-per-queue behavior. class JobRegistry { private jobs = new Map<string, JobRecord>(); + // Set by the graceful-shutdown reaper (shutdownCancel.ts) before it cancels + // every live job; see cancel(). + private shuttingDown = false; + + // The server is going down: the cancels that follow are nobody's decision. + beginShutdown(): void { + this.shuttingDown = true; + } register(record: JobRecord): void { this.jobs.set(record.id, record); @@ -245,11 +267,7 @@ class JobRegistry { ): void { const job = this.jobs.get(id); if (!job) return; - if (job.status === "queued" || job.status === "running") { - job.status = status; - job.endedAt = Date.now(); - if (typeof exitCode === "number") job.exitCode = exitCode; - } + markTerminal(job, status, exitCode); // Release per-task and drain references on terminal jobs. job.tasks = []; job.drainController = undefined; @@ -260,8 +278,22 @@ class JobRegistry { const job = this.jobs.get(id); if (!job) return false; if (job.status === "queued") { - // Remove from the scheduler (fires the queued job's onCancel), then mark - // it terminal. finalize's scheduler.complete is a no-op by then. + // Terminal FIRST, then out of the scheduler — which fires the queued + // job's onCancel, and a managed job persists its sidecar there + // (streamCommand.ts), so the record it writes must already say + // cancelled. It used to be marked after, and every job cancelled while + // queued kept "queued" in its .meta.json (release 13 slice W1). Not + // finalize() first: its scheduler.complete would take the entry out of + // the queue WITHOUT firing onCancel, and the job's stream and `done` + // would never settle. finalize() after is idempotent — it releases the + // task references, and scheduler.complete is a no-op by then. + // + // EXCEPT AT SHUTDOWN. The reaper cancels a queued job only so the exit + // cannot promote it into a child; nobody cancelled it. Its sidecar must + // stay `queued`, which is what the boot pass (bootQueuedJobs.ts) settles + // or re-queues on the next start — so it reaches onCancel still queued, + // and onCancel persists only a terminal record. + if (!this.shuttingDown) markTerminal(job, "cancelled"); getScheduler().cancel(id); this.finalize(id, "cancelled"); return true; diff --git a/common/jobs/shutdownCancel.ts b/common/jobs/shutdownCancel.ts @@ -37,6 +37,12 @@ export function armShutdownCancel(): void { // building a registry during shutdown just to cancel nothing would be // worse than useless. No registry means nothing ever ran. const registry = globalThis.__yttJobRegistry__; + // First: these cancels are the exit's, not an operator's, so a queued + // job keeps its `queued` sidecar for the boot pass (registry.cancel). + // Optional-called: under `next dev` the registry on globalThis can + // predate this method (HMR swaps the class, not the instance), and a + // throw here would skip every cancel below. + registry?.beginShutdown?.(); for (const job of registry?.list() ?? []) { if (job.status !== "running" && job.status !== "queued") continue; try { diff --git a/common/jobs/streamCommand.test.ts b/common/jobs/streamCommand.test.ts @@ -0,0 +1,205 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import type { JobMeta } from "./jobMeta"; +import { getRegistry, newJobId } from "./registry"; +import { + runManagedCommand, + runManagedFunction, + serialWriter, + type StreamActionResult, +} from "./streamCommand"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test jobs/streamCommand.test.ts +// +// A JOB CANCELLED WHILE STILL QUEUED (release 13 slice W1). Its start() never +// runs, so the terminal sidecar write in start()'s .finally never happens; +// onCancel is the only place that can write it, and it used to write nothing — +// `<id>.meta.json` kept the "queued" the enqueue wrote, and a boot pass could +// re-queue a job the operator had cancelled. Each test holds its own queue key +// with a job that runs until released, so the job under test only ever queues +// and nothing is spawned. Temp .jobs dirs only. + +type Started = Extract<StreamActionResult, { ok: true }>; + +function ok(r: StreamActionResult): Started { + if (!r.ok) throw new Error(r.error); + return r; +} + +async function jobsDir(): Promise<{ paths: Paths; root: string }> { + const root = await mkdtemp(path.join(tmpdir(), "stream-command-")); + return { paths: { jobsDir: path.join(root, ".jobs") } as Paths, root }; +} + +async function readMeta(paths: Paths, id: string): Promise<JobMeta | null> { + try { + return JSON.parse( + await readFile(path.join(paths.jobsDir, `${id}.meta.json`), "utf8"), + ) as JobMeta; + } catch { + return null; + } +} + +// The sidecar is written asynchronously and nothing hands out its promise, so +// wait (briefly) for it to reach `status`; report the last one seen otherwise. +async function metaReaches( + paths: Paths, + id: string, + status: string, +): Promise<JobMeta | null> { + let last: JobMeta | null = null; + for (let i = 0; i < 100; i++) { + last = await readMeta(paths, id); + if (last?.status === status) return last; + await new Promise((r) => setTimeout(r, 20)); + } + return last; +} + +// A job on `queueKey` that runs until released: everything submitted behind it +// queues. +async function hold(paths: Paths, queueKey: string) { + let release!: () => void; + const held = new Promise<void>((r) => { + release = r; + }); + const job = ok( + await runManagedFunction({ + kind: "test-holder", + queueKey, + paths, + fn: () => held, + }), + ); + return { + async release() { + release(); + await job.done; + }, + }; +} + +test("a function job cancelled while queued ends with a cancelled sidecar", async () => { + const { paths, root } = await jobsDir(); + const queueKey = `test:queued-cancel:${newJobId()}`; + const holder = await hold(paths, queueKey); + try { + const job = ok( + await runManagedFunction({ + kind: "test-queued", + queueKey, + paths, + fn: async () => { + throw new Error("must never start"); + }, + }), + ); + assert.equal(getRegistry().get(job.jobId)?.status, "queued"); + assert.equal((await metaReaches(paths, job.jobId, "queued"))?.status, "queued"); + + assert.equal(getRegistry().cancel(job.jobId), true); + assert.deepEqual(await job.done, { status: "cancelled", jobId: job.jobId }); + const meta = await metaReaches(paths, job.jobId, "cancelled"); + assert.equal(meta?.status, "cancelled"); + assert.equal(typeof meta?.endedAt, "number"); + assert.equal(meta?.startedAt, undefined, "it never started"); + } finally { + await holder.release(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a command job cancelled while queued ends with a cancelled sidecar", async () => { + const { paths, root } = await jobsDir(); + const queueKey = `test:queued-cancel-cmd:${newJobId()}`; + const holder = await hold(paths, queueKey); + try { + const job = ok( + await runManagedCommand({ + kind: "test-queued-command", + queueKey, + paths, + cwd: root, + // Never run: it only queues behind the holder. + command: "false", + args: [], + }), + ); + assert.equal((await metaReaches(paths, job.jobId, "queued"))?.status, "queued"); + assert.equal(getRegistry().cancel(job.jobId), true); + assert.equal((await job.done).status, "cancelled"); + assert.equal((await metaReaches(paths, job.jobId, "cancelled"))?.status, "cancelled"); + } finally { + await holder.release(); + await rm(root, { recursive: true, force: true }); + } +}); + +// The sidecar writer behind every job (metaWriter): each write starts only when +// the previous one has settled, a write that rejects does not stall the ones +// after it, and the last one rejecting is not an unhandled rejection (the test +// runner would fail this test on one). +test("serialWriter runs writes one at a time, in order, past a rejection", async () => { + const events: string[] = []; + let n = 0; + const write = serialWriter(async () => { + const i = ++n; + events.push(`start ${i}`); + // The first write is the slow one; unchained, the later ones would start + // (and end) while it is still in flight. + await new Promise((r) => setTimeout(r, i === 1 ? 40 : 1)); + events.push(`end ${i}`); + if (i === 2 || i === 4) throw new Error("a writer that rejects"); + }); + write(); + write(); + write(); + write(); + await new Promise((r) => setTimeout(r, 150)); + assert.deepEqual(events, [ + "start 1", "end 1", "start 2", "end 2", + "start 3", "end 3", "start 4", "end 4", + ]); +}); + +// THE ONE CANCEL THAT MUST NOT: the graceful-shutdown reaper cancels every +// queued job only so the exit cannot promote one into a child. Nobody cancelled +// it, and its `queued` sidecar is what the boot pass (bootQueuedJobs.ts) +// settles or re-queues on the next start. Last in the file: it puts the +// process's registry into shutdown, and replaces it afterwards. +test("at shutdown a queued job's sidecar stays queued, for the boot pass", async () => { + const { paths, root } = await jobsDir(); + const queueKey = `test:shutdown-cancel:${newJobId()}`; + const holder = await hold(paths, queueKey); + try { + const job = ok( + await runManagedFunction({ + kind: "test-queued", + queueKey, + paths, + fn: async () => { + throw new Error("must never start"); + }, + }), + ); + assert.equal((await metaReaches(paths, job.jobId, "queued"))?.status, "queued"); + getRegistry().beginShutdown(); + assert.equal(getRegistry().cancel(job.jobId), true); + // The job itself still settles, and the registry records the cancel. + assert.equal((await job.done).status, "cancelled"); + assert.equal(getRegistry().get(job.jobId)?.status, "cancelled"); + // Nothing rewrote the sidecar (a wrong write would land within ms). + await new Promise((r) => setTimeout(r, 200)); + assert.equal((await readMeta(paths, job.jobId))?.status, "queued"); + } finally { + await holder.release(); + globalThis.__yttJobRegistry__ = undefined; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts @@ -164,6 +164,32 @@ function makeDoneDeferred(jobId: string): { return { done, settle }; } +// ONE WRITER PER JOB, IN ORDER — DEFENSIVE. A job's sidecar is written at +// least twice (at enqueue, then with its terminal state) and each write is +// async. writeJobMeta snapshots the record before its first await, so two +// writes are ISSUED in order; only the fs threadpool could complete them out +// of order, leaving "queued" over "cancelled" or a shorter JSON over a longer +// one's tail. A 300-job probe (release 13 W1 review) never saw that happen +// without the chain. Chained, each write starts when the previous one has +// settled and serializes the record as it is THEN. +function metaWriter(paths: Paths, record: JobRecord): () => void { + return serialWriter(() => writeJobMeta(paths, record)); +} + +// Run `write` once per call, each call starting only after the previous one +// SETTLED — resolved or rejected. writeJobMeta never rejects today; a writer +// that did must not silently stall every later write, hence +// `then(write, write)`, nor raise an unhandled rejection from the last one, +// hence the no-op catch (the writer owns its errors, as writeJobMeta does). +// Exported for its unit test. +export function serialWriter(write: () => Promise<void>): () => void { + let last: Promise<void> = Promise.resolve(); + return () => { + last = last.then(write, write); + last.catch(() => {}); + }; +} + export async function runManagedCommand( opts: RunManagedCommandOpts, ): Promise<StreamActionResult> { @@ -180,6 +206,7 @@ export async function runManagedCommand( ); const { done, settle } = makeDoneDeferred(id); + const persistMeta = metaWriter(opts.paths, record); const safe = makeSafeController<string>(); let fileStream: WriteStream | null = null; @@ -244,23 +271,31 @@ export async function runManagedCommand( safe.safeClose(); requestSnapshotOnFinish(id, opts); // Persist terminal state (status/endedAt/exitCode now set by finalize). - void writeJobMeta(opts.paths, record); + persistMeta(); // Throttled retention so the .jobs directory stays bounded on its own. void maybePruneJobLogs(opts.paths); settle(record.status); }); }; + // Cancelled while still queued: start() never runs, so its .finally never + // writes the terminal sidecar — this does (release 13 slice W1; it used to + // stay "queued", and the next boot could re-queue a job the operator had + // cancelled). registry.cancel() marks the record cancelled BEFORE the + // scheduler fires this; at a graceful shutdown it does not, and a record + // still `queued` is left on disk as it is, for the boot pass. const onCancel = () => { cancelledBeforeStart = true; + if (record.status === "cancelled") persistMeta(); safe.safeClose(); settle("cancelled"); }; registry.enqueue(record, { start, onCancel }); // Persist queued/running identity up front so a mid-run crash still leaves a - // sidecar; the .finally above rewrites it with the terminal state. - void writeJobMeta(opts.paths, record); + // sidecar; the .finally above (or onCancel) rewrites it with the terminal + // state. + persistMeta(); return { ok: true, jobId: id, stream, done }; } @@ -305,6 +340,7 @@ export async function runManagedFunction( ); const { done, settle } = makeDoneDeferred(id); + const persistMeta = metaWriter(opts.paths, record); const safe = makeSafeController<string>(); let fileStream: WriteStream | null = null; @@ -384,22 +420,30 @@ export async function runManagedFunction( safe.safeClose(); requestSnapshotOnFinish(id, opts); // Persist terminal state (status/endedAt/exitCode now set by finalize). - void writeJobMeta(opts.paths, record); + persistMeta(); // Throttled retention so the .jobs directory stays bounded on its own. void maybePruneJobLogs(opts.paths); settle(record.status); }); }; + // Cancelled while still queued: start() never runs, so its .finally never + // writes the terminal sidecar — this does (release 13 slice W1; it used to + // stay "queued", and the next boot could re-queue a job the operator had + // cancelled). registry.cancel() marks the record cancelled BEFORE the + // scheduler fires this; at a graceful shutdown it does not, and a record + // still `queued` is left on disk as it is, for the boot pass. const onCancel = () => { cancelledBeforeStart = true; + if (record.status === "cancelled") persistMeta(); safe.safeClose(); settle("cancelled"); }; registry.enqueue(record, { start, onCancel }); // Persist queued/running identity up front so a mid-run crash still leaves a - // sidecar; the .finally above rewrites it with the terminal state. - void writeJobMeta(opts.paths, record); + // sidecar; the .finally above (or onCancel) rewrites it with the terminal + // state. + persistMeta(); return { ok: true, jobId: id, stream, done }; } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -5,6 +5,10 @@ - **The site build image runs Node 22 and pnpm 11**, the versions the rest of the workspace runs on, instead of Node 20 and pnpm 9, which did not read the workspace's install rules. The next Build all rebuilds the image from its first step, reinstalling every dependency, before it builds any site. - **Build all sites works in containers again.** Every site's container build had been failing while it prerendered `/favicon.ico`. Each site now builds from the data composed for it, never from files baked into the build image. A bundle whose `site.json` and `corpus.json` do not both name its site is refused before it is handed back or deployed. The image carries no corpus data, and its build context is about 7 MB from any checkout. - **A site deploy refuses a bundle that is not the site's own, and says why.** A site's **Build & deploy**, `pnpm ops build-deploy`, and Build & deploy all on a machine without containers used to ship whatever `export/out` held when the deploy began. If another site's build or the hub's had replaced it meanwhile, that is what shipped. Every site deploy now checks, just before handing the bundle to Cloudflare Pages, that its `site.json` and `corpus.json` both name the site. If they do not, it stops with `[deploy] REFUSED —` and what it found, and nothing is sent. Deploying a build that has a `site.json` but no matching `corpus.json` is refused before the job starts, as an incomplete build. +- **A Retry on the Diagnostics stage keeps its log when it empties its bucket.** Retrying the **Missing metadata.info.json**, **Archived ID with no directory** or **Skipped: live or upcoming** card, or the **Needs auth** or **Error** availability card, made the card vanish as soon as its bucket emptied, and the retry's log went with it. The card now stays until you leave the page, with its button greyed out at **Retry (0)**. A reload drops it, as before. The Download stage's cards have worked this way since 0.10.0. +- **The "built <when>" line in the Homepage section of `/sites` updates after a build.** When a **Build homepage** or **Build & deploy homepage** lane ends, the page re-renders, so the line says what **Deploy homepage** would ship now. It used to keep saying what `homepage/out` held when the page loaded. This happens whether the build succeeds, fails or is cancelled, because a failed build may already have changed `homepage/out`. +- **`/jobs` names the hub's and the homepage's jobs.** They show as **Build hub**, **Deploy hub**, **Build & deploy hub**, **Build homepage**, **Deploy homepage** and **Build & deploy homepage**, not as `build-hub`, `build-homepage` and so on. +- **A job cancelled before it started now stays cancelled.** Its record on disk kept saying "queued", so a restart could put a job you had just cancelled back in its queue, and a clip fetch cancelled while waiting could be reported as still queued. Jobs still waiting when the editor shuts down are handled as before: the next start settles or re-queues them. ## [0.11.0] - 2026-09-30 - **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites. diff --git a/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx b/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; import { AVAILABILITY_VALUES, @@ -153,7 +153,15 @@ export function DiagnosticsStage({ ariaLabel: "chat only pending", }, ]; - const populated = buckets.filter((b) => b.ids.length > 0); + // A RETRY EMPTIES ITS OWN BUCKET, and its card must outlive that (release 13 + // slice W1; plans/FACTS.md, "A run log lives in the panel's React state"). + // The run refreshes the page after every video it fetches, and a card whose + // bucket had gone to 0 was filtered out here — RetryBucketControl, its + // StreamActionLog and the log with it. `ran` holds the buckets whose Retry + // ran on this page; such a card stays, its button disabled at (0). A reload + // drops it as before. + const [ran, markRan] = useRanBuckets(); + const populated = buckets.filter((b) => b.ids.length > 0 || ran.has(b.ariaLabel)); const total = populated.reduce((acc, b) => acc + b.ids.length, 0); return ( <div className="flex flex-col gap-6"> @@ -265,6 +273,7 @@ export function DiagnosticsStage({ actionLabel={b.ariaLabel} defaultQueueKey={downloadDefaultQueueKey} existingQueues={existingQueues} + onRun={() => markRan(b.ariaLabel)} /> ) : null} </div> @@ -372,6 +381,18 @@ function VerifyResultBucket({ ); } +// The bucket keys whose Retry has run on this page (see DiagnosticsStage and +// AvailabilitySummary). Client state only: a reload starts empty. +function useRanBuckets(): [ReadonlySet<string>, (key: string) => void] { + const [ran, setRan] = useState<ReadonlySet<string>>(() => new Set()); + const markRan = useCallback( + (key: string) => + setRan((prev) => (prev.has(key) ? prev : new Set([...prev, key]))), + [], + ); + return [ran, markRan]; +} + function Heading({ title, desc }: { title: string; desc: string }) { return ( <div> @@ -672,12 +693,17 @@ function AvailabilitySummary({ existingQueues: string[]; downloadDefaultQueueKey: string; }) { + // Above the early return (hooks run on every render). A needs-auth or error + // retry does not empty its own bucket — only a probe rewrites + // availability.json — but a check landing while it streams does, and the + // card went with its log exactly as the channel-health cards did. + const [ran, markRan] = useRanBuckets(); const total = AVAILABILITY_VALUES.reduce( (acc, v) => acc + availability.byStatus[v].length, 0, ) + availability.unchecked.length; - if (total === 0) { + if (total === 0 && ran.size === 0) { return ( <p aria-label="availability summary empty" @@ -692,7 +718,7 @@ function AvailabilitySummary({ n: availability.byStatus[v].length, })).filter((x) => x.n > 0); const listed = AVAILABILITY_LIST_STATUSES.filter( - (v) => availability.byStatus[v].length > 0, + (v) => availability.byStatus[v].length > 0 || ran.has(v), ); return ( <div @@ -744,6 +770,7 @@ function AvailabilitySummary({ actionLabel={AVAILABILITY_LABELS[v].toLowerCase()} defaultQueueKey={downloadDefaultQueueKey} existingQueues={existingQueues} + onRun={() => markRan(v)} /> )} </div> diff --git a/editor/app/sites/components/HomepageBuildButtons.tsx b/editor/app/sites/components/HomepageBuildButtons.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import { MAX_PREVIEW_BRANCH, previewAliasUrl, @@ -13,7 +14,7 @@ import { deployHomepageAction, } from "../lib/homepageDeployActions"; import { useHydrated } from "../../operations/components/useOperationsStatus"; -import { JobLane } from "./JobLane"; +import { JobLane, type LaneOutcome } from "./JobLane"; type Lane = { kind: "build" | "build-deploy" | "deploy"; @@ -55,6 +56,7 @@ export function HomepageBuildButtons({ project, builtAt }: Props) { const [lane, setLane] = useState<Lane | null>(null); const [run, setRun] = useState(0); const hydrated = useHydrated(); + const router = useRouter(); const branch = preview.trim(); const problem = branch ? previewBranchProblem(branch) : null; @@ -67,6 +69,17 @@ export function HomepageBuildButtons({ project, builtAt }: Props) { setLane({ kind, key: next, preview: branch || undefined }); } + // "built <when>" is read from homepage/out when /sites renders, so a lane + // that BUILT has to re-render the page for the line to move (release 13 + // slice W1). Every outcome of a job, not only "done": a failed or cancelled + // build may already have rewritten or emptied homepage/out, and the line — + // which is what Deploy homepage would ship — must say so. "error" started no + // job, and a deploy writes nothing the page reads. StreamActionLog refreshes + // after any run that started, for the same reason. + function settled(l: Lane, outcome: LaneOutcome) { + if (l.kind !== "deploy" && outcome !== "error") router.refresh(); + } + function trigger(l: Lane) { const opts = l.preview ? { previewBranch: l.preview } : undefined; return l.kind === "build" @@ -187,6 +200,7 @@ export function HomepageBuildButtons({ project, builtAt }: Props) { : `homepage/out · ${project}${lane.preview ? ` (preview ${lane.preview})` : " (production)"}` } trigger={() => trigger(lane)} + onSettled={(outcome) => settled(lane, outcome)} /> )} </div> diff --git a/editor/app/sites/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx @@ -13,11 +13,19 @@ type LaneStatus = | "cancelled" | "error"; +// How a lane ENDS: its job's terminal status, or "error" when the trigger +// refused (or threw) before any job existed. +export type LaneOutcome = "done" | "failed" | "cancelled" | "error"; + type Props = { title: string; subtitle: string; // Launched once on mount. Returns the managed-job descriptor (or an error). trigger: () => Promise<StreamActionResult>; + // Called ONCE, when the lane reaches its terminal state, with that state — + // e.g. to refresh a server-rendered "built <when>" line after a build. Not + // called when the lane is unmounted first (another launch replaced it). + onSettled?: (outcome: LaneOutcome) => void; }; const CHIP: Record<LaneStatus, { label: string; cls: string }> = { @@ -35,7 +43,7 @@ const CHIP: Record<LaneStatus, { label: string; cls: string }> = { // the terminal status. A light poll surfaces queued/running + queue position // before the job reaches a terminal state. Mirrors StreamActionLog's plumbing in // miniature so several lanes can run at once on the batch panel. -export function JobLane({ title, subtitle, trigger }: Props) { +export function JobLane({ title, subtitle, trigger, onSettled }: Props) { const [status, setStatus] = useState<LaneStatus>("starting"); const [log, setLog] = useState(""); const [error, setError] = useState<string | null>(null); @@ -58,6 +66,16 @@ export function JobLane({ title, subtitle, trigger }: Props) { aliveRef.current = false; }; }, []); + // The latest onSettled, read when the lane ends: the launch effect below runs + // once, so a prop captured by its closure would be the first render's. And + // settle() fires it at most once — the launch is one-shot (startedRef), but + // "once" is this prop's contract, so it is guarded here rather than implied + // by the effect's shape (Strict Mode's mount → cleanup → mount included). + const onSettledRef = useRef(onSettled); + useEffect(() => { + onSettledRef.current = onSettled; + }); + const settledRef = useRef(false); useEffect(() => { // Strict-mode mounts effects twice in dev; guard so the job launches once. @@ -66,6 +84,14 @@ export function JobLane({ title, subtitle, trigger }: Props) { const unmounted = () => !aliveRef.current; let stopped = false; let pollTimer: ReturnType<typeof setTimeout> | null = null; + // The lane's terminal state: its chip, then the caller (once). + function settle(outcome: LaneOutcome) { + if (unmounted()) return; + setStatus(outcome); + if (settledRef.current) return; + settledRef.current = true; + onSettledRef.current?.(outcome); + } async function poll(id: string) { try { @@ -103,16 +129,14 @@ export function JobLane({ title, subtitle, trigger }: Props) { try { result = await trigger(); } catch (e) { - if (!unmounted()) { - setStatus("error"); - setError((e as Error).message); - } + if (!unmounted()) setError((e as Error).message); + settle("error"); return; } if (unmounted()) return; if (!result.ok) { - setStatus("error"); setError(result.error); + settle("error"); return; } setJobId(result.jobId); @@ -132,16 +156,14 @@ export function JobLane({ title, subtitle, trigger }: Props) { const term = await result.done; stopped = true; if (pollTimer) clearTimeout(pollTimer); - if (!unmounted()) { - setQueuePos(null); - setStatus( - term.status === "done" - ? "done" - : term.status === "failed" - ? "failed" - : "cancelled", - ); - } + if (!unmounted()) setQueuePos(null); + settle( + term.status === "done" + ? "done" + : term.status === "failed" + ? "failed" + : "cancelled", + ); })(); return () => { diff --git a/editor/e2e/diagnostics-retry-log.spec.ts b/editor/e2e/diagnostics-retry-log.spec.ts @@ -0,0 +1,159 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { test, expect } from "@playwright/test"; +import { + channelStage, + generateReport, + readJson, + resetData, + resolvePath, +} from "./helpers"; + +// A DIAGNOSTICS RETRY KEEPS ITS LOG WHEN ITS BUCKET EMPTIES (release 13 slice +// W1) — the Diagnostics twin of cookies-mode.spec's "defer" test (release 11 +// slice O3), which closed the same hole on the Download stage. +// +// A retry empties its own bucket, and the run refreshes the page after every +// video it fetches (recordTaskDone → the snapshot regen → the pulse → +// AutoRefresh), then once more when it ends. Both Diagnostics grids used to +// filter an emptied bucket's card out — `populated` for the channel-health +// cards, `listed` for the availability cards — and the card took the +// RetryBucketControl, its StreamActionLog and the run's log with it. Each test +// below waits for the refreshed, EMPTY list, which renders only inside a card +// that survived the refresh: on the old code that wait is what fails, every +// time, rather than the log check racing the refresh. + +const CHANNEL = "availability-test"; +const FIXTURE = "availability-baseline"; +const ROOT = `test-transcripts/channels/${CHANNEL}`; + +type Snapshot = { + buckets?: { noMetadata?: string[] }; + availability?: { byStatus?: Record<string, string[]> }; +}; + +async function writePlaylist(ids: string[]): Promise<void> { + // retry-bucket reads the channel playlist to learn each id's URL. + await writeFile( + resolvePath(`${ROOT}/playlist`), + ids.map((id) => `https://www.youtube.com/watch?v=${id}\n`).join(""), + ); +} + +async function writeAvailability(id: string, availability: string) { + const path = resolvePath(`${ROOT}/data/${id}/availability.json`); + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + JSON.stringify({ + checkedAt: new Date().toISOString(), + availability, + webpageUrl: `https://www.youtube.com/watch?v=${id}`, + }), + ); +} + +async function waitForSnapshot( + read: (snap: Snapshot) => number | undefined, + size: number, +): Promise<void> { + await expect + .poll( + async () => { + const snap = await readJson<Snapshot>(`${ROOT}/snapshot.json`).catch( + () => null, + ); + return snap ? (read(snap) ?? -1) : -1; + }, + { timeout: 15_000 }, + ) + .toBe(size); +} + +test("channel health: a Retry that empties Missing metadata keeps its card and its log", async ({ + page, +}) => { + test.setTimeout(90_000); + await resetData(FIXTURE); + // A video directory with nothing in it: no metadata.info.json, so it is in + // the retryable "Missing metadata.info.json" bucket. The retry's download + // writes the metadata, which is what empties the bucket. + const ID = "vidnometa1"; + await mkdir(resolvePath(`${ROOT}/data/${ID}`), { recursive: true }); + await writePlaylist([ID]); + + await generateReport(page, CHANNEL); + await waitForSnapshot((s) => s.buckets?.noMetadata?.length, 1); + await page.goto(channelStage(CHANNEL, "diagnostics")); + + const card = page.getByLabel("retry missing metadata bucket"); + await card.getByRole("button", { name: /^Retry \(1\)$/ }).click(); + const log = page.getByLabel("Retry missing metadata output"); + await expect(log).toContainText("Retry allowlist: kept 1 of 1", { + timeout: 30_000, + }); + await expect(log).toContainText("Managed download complete", { + timeout: 30_000, + }); + + // The bucket empties once the snapshot regenerates… + await waitForSnapshot((s) => s.buckets?.noMetadata?.length, 0); + // …and the page refreshes onto the EMPTY list, inside the card that must + // have survived that refresh, with the run's log still in it. + await expect(page.getByLabel("missing metadata empty")).toBeVisible({ + timeout: 20_000, + }); + await expect( + page.getByRole("heading", { name: "Missing metadata.info.json (0)" }), + ).toBeVisible(); + await expect(log).toContainText("Managed download complete"); + await expect(card.getByRole("button", { name: /^Retry \(0\)$/ })).toBeDisabled(); + // "All clear." is what the grid used to fall back to once its only card went. + await expect(page.getByLabel("channel health empty")).toHaveCount(0); + + // A fresh page has nothing to show for an empty bucket. + await page.reload(); + await expect(page.getByLabel("channel health empty")).toBeVisible(); + await expect(page.getByLabel("retry missing metadata bucket")).toHaveCount(0); +}); + +test("availability: a Needs auth card whose bucket empties during its Retry keeps its log", async ({ + page, +}) => { + test.setTimeout(90_000); + await resetData(FIXTURE); + const ID = "vidneedsauth1"; + await writeAvailability(ID, "needs_auth"); + await writePlaylist([ID]); + + await generateReport(page, CHANNEL); + await waitForSnapshot((s) => s.availability?.byStatus?.needs_auth?.length, 1); + await page.goto(channelStage(CHANNEL, "diagnostics")); + const card = page.getByLabel("retry needs auth bucket"); + await expect(card.getByRole("button", { name: /^Retry \(1\)$/ })).toBeVisible(); + + // A retry never rewrites availability.json — a download records its + // availability history only, with the top level untouched — so what empties + // an availability bucket is a PROBE. Land one now, as a check running beside + // the retry would: the page still shows the snapshot it rendered from, and + // the regeneration the retry's own download triggers is the first to read it. + await writeAvailability(ID, "public"); + + await card.getByRole("button", { name: /^Retry \(1\)$/ }).click(); + const log = page.getByLabel("Retry needs auth output"); + await expect(log).toContainText("Managed download complete", { + timeout: 30_000, + }); + + await waitForSnapshot((s) => s.availability?.byStatus?.needs_auth?.length, 0); + await expect(page.getByLabel("availability needs_auth empty")).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByRole("heading", { name: "Needs auth (0)" })).toBeVisible(); + await expect(log).toContainText("Managed download complete"); + await expect(card.getByRole("button", { name: /^Retry \(0\)$/ })).toBeDisabled(); + + await page.reload(); + await expect(page.getByLabel("availability public count")).toBeVisible(); + await expect(page.getByLabel("retry needs auth bucket")).toHaveCount(0); +}); diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts @@ -7,21 +7,28 @@ // from a spec would be a real one. So: // - no spec clicks Deploy homepage, and none ticks Deploy after build and // then clicks Build homepage; -// - the one spec that clicks Build homepage first holds BOTH the `build` and +// - the two specs that click Build homepage first hold BOTH the `build` and // the `deploy` queue with fabricated jobs (/api/test/stuck-job, never -// released here), so the job it starts only ever QUEUES — the +// released here), so the job each starts only ever QUEUES — the // build-homepage it expects on `build`, and equally a build-deploy-homepage // on `deploy` if a regression turned the click into a build-and-deploy — -// and it is cancelled from its own lane while still queued. Its start -// function never runs: no log file, no child, nothing written to -// homepage/public or homepage/out. If the spec fails before its Cancel, the -// next resetData cancels jobs newest first, so the queued job is removed -// before a holder's slot is freed. +// and it is cancelled while still queued: from its own lane, or (the +// re-render spec) by the harness reset. Its start function never runs: no +// log file, no child, nothing written to homepage/public or homepage/out. +// If a spec fails before its cancel, the next resetData cancels jobs newest +// first, so the queued job is removed before a holder's slot is freed. import { readdir } from "node:fs/promises"; import { test, expect, type Page } from "@playwright/test"; import { baseUrl } from "./baseUrl"; -import { pathExists, readJson, resetData, resolvePath } from "./helpers"; +import { + pathExists, + readJson, + resetData, + resolvePath, + writeSettings, + writeSite, +} from "./helpers"; const group = (page: Page) => page.getByRole("group", { name: "Homepage build" }); @@ -205,4 +212,49 @@ test("Build homepage starts a build-homepage job on the build queue (held there, expect(log.status).toBe("cancelled"); expect(log.content).toBe(""); expect(await pathExists(`test-transcripts/.jobs/${job.id}.log`)).toBe(false); + // …and its sidecar says so too (release 13 slice W1). A job cancelled while + // queued used to keep the "queued" its enqueue wrote: the cancel path never + // rewrote the meta, so a restart's boot pass could re-queue a job the + // operator had cancelled, and a cancelled clip fetch could read as queued. + await expect + .poll(async () => (await metasOfKind("build-homepage"))[0]?.status) + .toBe("cancelled"); +}); + +test("a build lane that ends re-renders /sites, so the \"built <when>\" line is current", async ({ + page, + request, +}) => { + // The line reads homepage/out when /sites renders, and nothing here may + // build (see the header) — so the proof is that the page RE-RENDERS when the + // lane ends: a site written to disk after the page loaded appears without a + // reload. Passive refresh is off (it would re-render on the job's status + // change by itself), and the job is cancelled from OUTSIDE the page, through + // the harness reset, because the lane's own Cancel is a server action that + // revalidates, and its response re-renders the page on its own. + await writeSettings({ autoRefreshIntervalSeconds: 0 }); + for (const queue of ["build", "deploy"]) { + const hold = await request.get( + `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`, + ); + expect(hold.ok(), queue).toBe(true); + } + + await openSites(page); + await buildButton(page).click(); + await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 }); + + await writeSite("refresh-probe"); + const probe = page.locator('a[href="/sites/refresh-probe"]'); + await expect(probe).toHaveCount(0); + + // Cancels every live job, newest first: the queued build before either + // holder's slot is freed, so nothing is promoted and nothing builds. + const reset = await request.get(`${baseUrl}/api/test/invalidate-cache`); + expect(reset.ok()).toBe(true); + + await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + await expect(probe).toBeVisible({ timeout: 15_000 }); }); diff --git a/editor/e2e/transcript-source.spec.ts b/editor/e2e/transcript-source.spec.ts @@ -2,15 +2,16 @@ import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { channelStage, - generateReport, pathExists, + readJson, resetData, resolvePath, } from "./helpers"; const CHANNEL = "test-youtube"; const VIDEO_DIR = "20240101_test1234567"; -const DATA = `test-transcripts/channels/${CHANNEL}/data/${VIDEO_DIR}`; +const ROOT = `test-transcripts/channels/${CHANNEL}`; +const DATA = `${ROOT}/data/${VIDEO_DIR}`; const VIDEO_URL = `/channels/${CHANNEL}/videos/${VIDEO_DIR}`; const VTT = "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nhello\n"; @@ -80,8 +81,36 @@ test("diagnostics list a video with only transcript.en-US.vtt", async ({ await rm(resolvePath(`${DATA}/transcript.en.vtt`), { force: true }); await writeFile(resolvePath(`${DATA}/transcript.en-US.vtt`), VTT); - // First channel-page load generates a fresh snapshot including the bucket. - await generateReport(page, CHANNEL); + // A REPORT OF THE SWAPPED TREE, NOT JUST A REPORT. resetData's + // invalidate-cache clears the snapshot scheduler's TIMER, but a regeneration + // already in flight (armed by the previous spec's action) runs to the end + // and writes snapshot.json from the tree it read — before the swap above. + // generateReport returns as soon as any snapshot.json exists, so this test + // used to render that stale report: 1 failure in release 11's full suite, + // 9/9 alone. So: drop whatever is there, then regenerate until the report + // shows the bucket (chat-only.spec's refreshReport does the same). + await rm(resolvePath(`${ROOT}/snapshot.json`), { force: true }); + await page.goto(`/channels/${CHANNEL}`); + const refresh = page.getByRole("button", { name: /refresh report/i }); + await refresh.waitFor({ state: "visible" }); + await expect + .poll( + async () => { + // Retried: a click before hydration fires nothing, and a stale write + // landing after a good one is answered by the next click. + await refresh.click({ timeout: 5_000 }).catch(() => {}); + for (let i = 0; i < 20; i++) { + const snap = await readJson<{ + buckets?: { nonStandardVtt?: string[] }; + }>(`${ROOT}/snapshot.json`).catch(() => null); + if (snap?.buckets?.nonStandardVtt?.length === 1) return true; + await new Promise((r) => setTimeout(r, 250)); + } + return false; + }, + { timeout: 60_000, intervals: [1000] }, + ) + .toBe(true); await page.goto(channelStage(CHANNEL, "diagnostics")); await expect( diff --git a/editor/package.json b/editor/package.json @@ -10,6 +10,7 @@ "build": "next build", "start": "UV_THREADPOOL_SIZE=${UV_THREADPOOL_SIZE:-16} next start --port ${EDITOR_PORT:-3001}", "lint": "eslint", + "test": "tsx --test \"app/**/*.test.ts\"", "e2e": "node ../scripts/queue-lock.mjs --ports PORT:3011,EXPORT_PORT:3010,OLLAMA_STUB_PORT:11435 -- playwright test", "e2e:ui": "playwright test --ui" }, diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -3779,7 +3779,13 @@ above: `ranHere` set in the trigger (and `onRun` to tell the card), null only wh stage's Fetch audio control is covered too (its section stays while `autoSubsCount > 0`). **NOT Diagnostics:** its two grids drop an emptied bucket's card before the control renders (`DiagnosticsStage.tsx:156` `populated`, `:694` `listed`), so a Diagnostics retry that empties its -bucket still loses its log — open. +bucket still loses its log — open. **Closed in release 13 slice W1:** both grids keep a card whose +Retry ran on the page — a `ran` set (`useRanBuckets` in `DiagnosticsStage.tsx`), filled by +`RetryBucketControl`'s `onRun`, keyed by the card's aria label (channel health) or status +(availability); `AvailabilitySummary`'s hook sits above its `total === 0` return, which yields to +a ran card. `diagnostics-retry-log.spec.ts` waits for the refreshed EMPTY list in each grid. An +availability bucket is never emptied by its own retry (a download records availability history +only, `updateTopLevel: false`); a probe landing mid-run empties it. The persist case needed a fixture: `editor/e2e/fixtures/bin/fake-ytdlp.mjs:680-725` (at `12d1778`) grows an app-extraction branch — the LAST branch checked, matched on the media @@ -6547,6 +6553,13 @@ Line numbers are `plans/FACTS.md` lines at `e172749b`, before this record's in-p timeout of its own) and logged when it fires; the storage pass is not cancelled. `cancelReason` is drawn on `/jobs` (`JobListEntry` → `JobRowView.cancelReason`, only on a `cancelled` meta; `data-testid="cancel-reason"` on the row and the job page's "Cancelled because" cell). + **Release 13 (W1): an operator's cancel of a QUEUED job now writes its `cancelled` sidecar** + (before, it kept `queued`, and this pass could re-queue it). `registry.cancel()` marks the record + terminal (`markTerminal`) before `scheduler.cancel()` fires `onCancel`, and streamCommand's + `onCancel` persists a terminal record. **Graceful shutdown still leaves `queued`:** + `shutdownCancel.ts` calls `registry.beginShutdown()` first, so a queued job reaches `onCancel` + still queued and nothing is written — this pass keeps its input. A job's meta writes are chained + (`metaWriter` in `streamCommand.ts`), so the enqueue's write never lands after the terminal one. - **The hub embeds `public/hub-summary.json`** at `compose:hub` (`common/controller/poolSummary.ts` shared with `compose-homepage`; `common/lib/hubSummary.ts` projects `official` + per-site figures from the same `buildHomepageSummary`). Optional end to end: missing/404/malformed → cards without diff --git a/plans/release-13.md b/plans/release-13.md @@ -614,4 +614,358 @@ sides. |---|---|---|---| | 1 | `f7a047ee` | `ops-api`, `deploy-page`, `site-scope` | **40 passed**, 0 failed, 3.2 min; 7.6 min wall, of which 4.3 min waiting in the queue behind W1 | +### Slice W1, as shipped — the editor lows (2026-09-28) + +Branch `r13/lows-editor` off `main` `bf6904e8` (`441bdbb2` merged first, fast-forward), worktree +`/home/user/Projects/r13-lows-editor`, one Opus implementer beside W2 and W3. Seven lows left by +release 11. No settings, site or channel key; nothing on disk moves. Scratch files `w1-*` in the +job's `tmp/overnight`. + +**1 — a Diagnostics retry keeps its card and its log** (`DiagnosticsStage.tsx`). +- The O3 hole on the other stage: both Diagnostics grids dropped an emptied bucket's card + (`populated` for channel health, `listed` for availability), and a retry empties its bucket while + it streams (the per-video snapshot regen, then the end-of-run refresh). The card took + `RetryBucketControl`, its `StreamActionLog` and the log with it. +- `useRanBuckets()`: a `ran` set of the buckets whose Retry ran on this page, filled by + `RetryBucketControl`'s existing `onRun`. Keyed by the card's aria label (channel health) or its + status (availability). A ran card stays, its button disabled at **Retry (0)** (the control's own + `ranHere` already did that). `AvailabilitySummary`'s hook sits above its `total === 0` early + return, and that return yields to a ran card. A reload drops an empty card. Labels and test ids + unchanged. +- **An availability bucket is never emptied by its own retry:** a download records availability + *history* only (`updateTopLevel: false`), so only a probe rewrites `availability.json`. The + spec lands one before the click (as a check running beside the retry would), and the regen the + retry's download triggers is the first to read it. +- `diagnostics-retry-log.spec.ts` (new, 2), modelled on `cookies-mode.spec.ts:241`: a **Missing + metadata.info.json** retry (an empty `data/vidnometa1/`; the fake writes the metadata) and a + **Needs auth** retry. Each waits for the refreshed EMPTY list, which renders only inside a card + that survived, then asserts the log, the disabled **Retry (0)**, and that a reload drops it. + +**2 — the `transcript-source.spec.ts:76` flake** (spec only). +- The cause is the prompt's: `resetData`'s invalidate-cache clears the snapshot scheduler's TIMER, + but a regeneration already in flight runs on and writes `snapshot.json` from the tree it read, + before the spec's swap. `generateReport` returns as soon as any `snapshot.json` exists. The race + is the harness reset's, not product code's: in production a change made through the app arms its + own regen after the fact. +- The test now deletes `snapshot.json` after the swap and clicks **Refresh report** until the report + shows `nonStandardVtt` with one video (`chat-only.spec`'s `refreshReport` shape). + +**3 — `/sites` "built <when>" refreshes when a homepage build lane ends** (`JobLane.tsx`, +`HomepageBuildButtons.tsx`). +- `JobLane` gets `onSettled(outcome)`, with `LaneOutcome = "done" | "failed" | "cancelled" | + "error"` ("error" = the trigger refused or threw, no job). It is called once, from one `settle()` + that also sets the chip. The prop is read through a ref, because the one-shot launch effect would + otherwise call the first render's. A `settledRef` makes "once" the prop's contract, not the + effect's shape (O4's Strict Mode fix is untouched). It is not called for a lane that a newer + launch unmounted. +- `HomepageBuildButtons` calls `router.refresh()` when a lane that BUILDS (**Build homepage**, + **Build & deploy homepage**) ends with any job outcome. **Wider than the prompt's "ends done", on + purpose:** a failed or cancelled build may already have rewritten or emptied `homepage/out`, and + the line is what **Deploy homepage** would ship. `StreamActionLog` refreshes after any run that + started, for the same reason. No refresh on "error" or for a deploy lane. +- **AutoRefresh already covered most of it:** the pulse token carries each job's status and + `endedAt`, so with passive refresh on (5 s by default) `/sites` re-rendered once the job ended + anyway. The explicit refresh is immediate, and it is the only one when + `autoRefreshIntervalSeconds` is 0. +- **e2e cannot build the homepage** (O4's rule: `homepage/out` is the checkout's own directory), so + the new `sites-homepage.spec` test proves the RE-RENDER on the one terminal state it may reach, + a cancel: + - passive refresh is off, and both queues are held, as before; + - Build homepage queues, then a site is written to disk; + - the queued job is cancelled from OUTSIDE the page, through the harness reset (newest first, so + nothing is promoted). The lane's own Cancel is a server action that revalidates, and its + response re-renders the page by itself (`server-action-reducer.js`: a revalidating action + navigates to the current URL); + - the new site's link appears without a reload. + +**4 — `/jobs` labels** (`jobKinds.ts`). +- Six entries: `build-hub` **Build hub**, `deploy-hub` **Deploy hub**, `build-deploy-hub` + **Build & deploy hub**, `build-homepage` **Build homepage**, `deploy-homepage` **Deploy + homepage**, `build-deploy-homepage` **Build & deploy homepage** (the `/sites` lanes' titles). +- Shape: `queueKeyStrategy: "custom"` (`BUILD_QUEUE` / `DEPLOY_QUEUE`), not drainable, not + replayable (no JobSpec), no `needsMedia` (no channelSlug, and none of them opens a channel's + `data/`). +- **Correction to the prompt:** + - There are no "existing build/deploy entries" to copy. No publish kind has an entry (O4's record + says so), so the shape is the table's own. + - The prompt named four kinds. `deploy-hub` and `build-deploy-hub` are in too, so the hub's trio + is not half-labelled. +- **Consumers checked:** + - `jobKindLabel` renders on `/jobs` (`JobRow`, `JobsTable`) and on the job page. + - `jobKinds.test.ts` enumerates the table: `ADDED_KINDS` +6. + - No spec matches these kinds by row text. `ops-api` uses them as API verbs only, and + `sites-homepage` reads `kind` from the metas. + - The publish kinds (`build-export`, `build-deploy`, `deploy-export`, `build-index`, …) are still + unlabelled. Left: not asked. + +**5 — a job cancelled while queued writes its cancelled sidecar** (`registry.ts`, +`streamCommand.ts`, `shutdownCancel.ts`). +- **The bug.** `onCancel` only closed the stream and settled `done`, and `registry.cancel()` marked + the record terminal only after `scheduler.cancel()` had fired it. The sidecar kept its enqueue's + `queued`. So the release-9 boot pass could **re-queue a job the operator had cancelled** (the + newest of its spec, under 24 h), and `/api/media/fetch-window/<id>` (MCP `fetch_clip`) could + report a cancelled, evicted fetch as still `queued`. (`/jobs` never showed it as queued: such a + job opened no log, so once evicted it is not listed, and a non-terminal meta reads "archived".) +- **The fix.** `registry.cancel()` marks a queued record terminal (`markTerminal`, now shared with + `finalize`) BEFORE `scheduler.cancel()`. `finalize()` cannot go first: its `scheduler.complete` + would drop the entry without firing `onCancel`, and `done` would never settle. Both `onCancel`s + persist the sidecar when the record is terminal. +- **Found: the naive fix breaks the boot pass.** `shutdownCancel.ts` cancels every queued job on + SIGTERM/SIGINT (so the exit cannot promote one into a child), and `bootQueuedJobs.ts` exists to + settle exactly those `queued` metas on the next start. A graceful restart would have written + `cancelled` over each (or torn it mid-exit). So `registry.beginShutdown()`, called first by the + reaper, makes `cancel()` skip the early mark. A queued job then reaches `onCancel` still queued, + and nothing is written. **`shutdownCancel.ts` is outside the prompt's Owns list** (no other slice + owns it); the change is three lines. The call is optional (`?.()`), because under `next dev` the + registry on `globalThis` can predate the method. +- **Meta writes are chained** (`metaWriter`, over `serialWriter` since the fix round). This is + **defensive, not a fix for an observed race**: + - `writeJobMeta` snapshots the record before its first `await`, so two writes are issued in + order, and only the fs threadpool could complete them out of order; + - the review's probe cancelled 300 jobs in the same tick as their enqueue, and all 300 ended + `cancelled` with or without the chain. +- **Tests.** `registry.test.ts` +1: `onCancel` sees the record already `cancelled`, with `endedAt`. + `streamCommand.test.ts` (new, 3): a function job and a command job queued behind a holder, + cancelled, end `cancelled` on disk with no `startedAt`; after `beginShutdown()` the sidecar stays + `queued` (that test replaces the process registry afterwards). `sites-homepage.spec`'s Build + homepage test polls the job's sidecar to `cancelled` (the UI path). + +**6 — L7: doctor's worker engine from `paths`** (`doctor.ts`, the engine line only). The default +engine was `app.defaultBin()`, which is this process's `getPaths()`. Now it is `paths.whisperBin` for +whisper-cpp and `paths.parakeetBin` for parakeet, as the model line beside it uses `paths`. chough +has no Paths field, so it keeps its app default. `doctor.test.ts` +1, and the test Paths gain +`whisperBin` and `parakeetBin`. W3 adds an image check to the same file. + +**7 — `editor/package.json` `test`:** `tsx --test "app/**/*.test.ts"`. It works as `pnpm test` in +`editor/` and as `pnpm --filter editor test` from the root: 85/85. `plans/tools/implementer-rules.md` +is W2's, so the gate-list line is in the report for the parent to join. + +| sha | what | +|---|---| +| `2fa120cd` | 1: `useRanBuckets`, both grids keep a ran card; `diagnostics-retry-log.spec.ts` (new, 2) | +| `aad7b3bf` | 2: `transcript-source.spec` drops `snapshot.json` after the swap and refreshes until the bucket reads 1 | +| `cd0781f5` | 5: `markTerminal` before `onCancel`, `onCancel` persists a terminal record, `beginShutdown`, `metaWriter`; `registry.test.ts` +1, `streamCommand.test.ts` (new, 3) | +| `5c6118df` | 3: `JobLane` `onSettled`; `HomepageBuildButtons` refreshes after a build lane; `sites-homepage.spec` +1, and the Build test polls the sidecar | +| `79997a8d` | 4: six `JOB_KINDS` entries; `jobKinds.test.ts` `ADDED_KINDS` +6 | +| `6a805323` | 6: doctor's default engine from `paths`; `doctor.test.ts` +1 | +| `c91c9ce9` | 7: the editor `test` script | +| `5da692f7` | `changelog:` `[Unreleased]` above `[0.10.0]` in `editor/CHANGELOG.md` (items 1, 3, 4, 5) | +| _this_ | `plans:` this record; FACTS (the bucket-card paragraph closed, the boot-pass bullet amended) | + +**Gates** (logs `w1-*.log`): +- **tsc** (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) was clean on the full + tree before the code commits (`w1-tsc1.log`). The commits split that tree by file, and none + depends on another. +- **Unit tests** (`w1-units1.log`, on `c91c9ce9`): + - **common 2,119/2,119.** That is +5: registry +1, streamCommand +3, doctor +1. jobKinds grew + inside an existing test. So the base at `bf6904e8` is 2,114, not the prompt's 2,112. + - **editor unit 85/85**, **`test:scripts` 185 + 1 skipped of 186**, **mcp 269/269**. +- **Editor build** `pnpm --filter editor exec next build` ok (`w1-build1.log`, compiled in 16 s). + The export build was not run: nothing under `export/` changed. +- **e2e**, all queued and detached. The lock was free each time. `export/public` was linked per + path, with no dangling links. Mid-slice, an added worktree moved this one's block to 4311/4310. + + | run | specs | passed | failed | time | + |---|---|---|---|---| + | A | `retry-bucket transcript-source cookies-mode sites-homepage jobs-retry diagnostics-retry-log queues cancel ops-api exclude-from-counts availability`, `--repeat-each=3` (186) | **109** | 77 | 10.7 min | + | A2 | `transcript-source diagnostics-retry-log sites-homepage retry-bucket queues`, `--repeat-each=3` | **57** | **0** | 7.2 min | + | B (bite) | `diagnostics-retry-log sites-homepage` on `main`'s six source files | 2 | 4 | 2.1 min | + + - **Run A was killed by the machine, not the code.** At 13:00:39 the kernel OOM killer ran (user + journal: `session.slice: The kernel OOM killer killed some processes`). The live editor's + parakeet worker held 2.5 GB. After test 110 every request got `ERR_CONNECTION_REFUSED`. + - Tests 1–109 were the whole first repetition, all 11 specs 62/62, and the second through + `queues.spec:40`. + - A2 re-ran, ×3, everything the second and third repetitions had not reached, plus + `transcript-source` (**9/9** there; **3/3** in A's first repetition). + - `queues` (`cancels a queued job without disturbing…`) and `cancel` exercise the changed cancel + path; `ops-api` polls metas; `exclude-from-counts` and `availability` render the two grids. + - Checked after every run: the worktree has no `homepage/out`, and `homepage/public` holds no + ignored data. +- **Numbers tool:** none. + +**They bite:** +- **Unit** (`w1-bite-units.log`, `main`'s files swapped in, then restored by a trap): + - item 5: `main`'s registry, streamCommand and shutdownCancel fail 4 of 10. That is the registry + test, both sidecar tests, and the shutdown test (no `beginShutdown` there). + - **The naive fix** (`onCancel` always persists, `cancel()` always marks first) fails the shutdown + test, 1 of 10, which is the guard for the boot pass. + - The new registry with `main`'s `onCancel` (writes nothing) fails both sidecar tests, 2 of 3. + - item 4: `main`'s `jobKinds.ts` fails "added kinds carry their pinned label", 1 of 4. + - item 6: `main`'s `doctor.ts` fails the new test, 1 of 9. +- **e2e** (run B, `w1-e2e-bite.log`; `main`'s `DiagnosticsStage`, `JobLane`, `HomepageBuildButtons`, + `registry`, `streamCommand`, `shutdownCancel`): + - both Diagnostics tests fail at the empty-list wait (`missing metadata empty`, `availability + needs_auth empty`), and the grid had dropped the card; + - the Build homepage test fails at the sidecar poll (`Expected "cancelled"`, `Received "queued"`); + - the re-render test fails at the new site's link; + - the two untouched `sites-homepage` tests pass. +- **Item 2 cannot be made to bite on demand:** it is a race between a previous spec's in-flight + regen and this one. The fix is structural (no stale `snapshot.json` can satisfy the wait), and + `transcript-source` passed 12 of 12 across A and A2. +- **Item 7** is a script, not a test. + +**Found and left** +- **`shutdownCancel.ts` was touched** (item 5, above), outside the prompt's Owns list. +- **The hub's lanes do not refresh `/sites` when they end** (`HubBuildButtons`, not this slice's + file). Nothing there reads the hub's `export/out` build time the way the homepage line does, so + there is nothing stale to show. +- **The publish kinds are still unlabelled on `/jobs`** (`build-export`, `build-deploy`, + `deploy-export`, `build-index`, `build-stats`, `archive-*`). +- **Run A's OOM:** the machine has 15 GB, and the live parakeet worker plus several worktrees' + servers crowd it. A full-suite gate tonight may meet the same killer. +- **`shuttingDown` is one-way and process-wide** (review L2; the gaps predate this slice): + - Once `beginShutdown()` has run, no queued cancel writes a sidecar. That includes an operator's, + or a remote worker requester's, landing during Next's graceful close (`server.close` waits for + in-flight requests). This is the old behaviour. + - A job enqueued in that window is never reaped. If it starts, its child can outlive the exit, + and its meta stays `running`, which the boot pass leaves alone. + - The flag is the natural hook for a follow-up in which `enqueue` refuses, or at least does not + start, a job once `shuttingDown` is set. It was not considered for this slice (the review's + question), and it is not done. +- **A job RUNNING at a graceful restart ends `cancelled` on disk, with no `cancelReason`** (review + L3). Its `.finally` still writes while Next closes, so on `/jobs` a restart reads like an + operator's cancel. This is unchanged by this slice, whose "nothing is written" is about queued + jobs only. +- **`onSettled` does not fire for a lane a newer launch replaced** (review nit). Build homepage + followed at once by Deploy homepage: the build ends unseen, and "built <when>" stays stale until + AutoRefresh (5 s by default) or a reload. The prop's comment says so. +- **The e2e harness reset now writes `cancelled` metas** (review nit). `invalidate-cache` cancels + queued jobs, which write their sidecar asynchronously during `resetData`'s `rm`. It is the same + class as a running job's terminal write, and `rm`'s `maxRetries` already absorbs it. + +### Slice W1, fix round (2026-09-28) + +Review: **SHIP AFTER FIXES** (`w1-review.md`). One should-fix, L1 taken, L2, L3 and the two nits +recorded above. `main` was not merged. + +- **Should-fix:** the item-5 changelog bullet claimed `/jobs` listed an evicted, cancelled job as + queued again. It never did: such a job opened no log, so once evicted it is not listed, and a + non-terminal meta reads "archived". + - The real consequences were the boot pass re-queueing it, and `/api/media/fetch-window/<id>` + (MCP `fetch_clip`) reporting a cancelled fetch as still `queued`. + - The bullet now says that. So do this record's item 5 and the `sites-homepage.spec` comment. + - `cd0781f5`'s commit message repeats the old claim, and it is left as it is. +- **L1:** the chain is `serialWriter(write)`, exported for its test. It is + `last = last.then(write, write)` plus a no-op `last.catch`: + - a writer that rejects no longer stalls every later write; + - the last write rejecting is not an unhandled rejection. + - `writeJobMeta` never rejects, so this is defence only. + - `streamCommand.test.ts` +1: four writes, the first slow and the second and fourth rejecting, + must run one at a time, in order, with no unhandled rejection. + - The test fails on each of three weaker variants: `.then(write)` stops after write 2; without + the catch, the runner reports an `unhandledRejection`; unchained writes interleave. +- **Found while re-gating: `common/node_modules` had been replaced** at 13:25:02, after the + hand-back and not by this slice, with a standalone (non-workspace) install carrying + `@types/react` 19.3.0. + - `tsc` then failed in `export` (`PlayerProvider.tsx:1186`: two unrelated `Ref` types). + - The stray tree was moved to `$T/w1-stray-common-node_modules-1325` (530 MB, not deleted), and + `pnpm install --frozen-lockfile --offline` relinked the workspace ("Already up to date", + 3.4 s). + - The sibling worktrees' `common/node_modules` are workspace symlinks, as this one is again. + +| sha | what | +|---|---| +| `60ac51a7` | `changelog:` the item-5 bullet, the record's item 5 and the spec comment say what the stale `queued` actually did | +| `c3afe68d` | `jobs:` `serialWriter`: `then(write, write)` plus a no-op catch; `streamCommand.test.ts` +1 | +| _this_ | `plans:` this fix round; the chain described as defensive; L2, L3 and the nits recorded | + +**Gates** (`w1-fix-gates2.log`, on the relinked tree): +- tsc clean. +- **common 2,120/2,120** (+1, the serialWriter test) and **editor unit 85/85**. +- The first attempt (`w1-fix-gates.log`) ran on the stray tree: tsc failed. Its common run was + stopped after the tree moved out from under it, and it has no result. +- No e2e and no build: the changed spec's diff is a comment, and the rest is common code under unit + tests, plus docs. + +#### Brought to main (2026-09-30) + +Release 13 was reviewed SHIP on 2026-09-28 and never merged: `main` went on through releases 14, 15 +and 16 and the 0.11.0 cut, to `7a77536b`, 253 commits past this branch's fork (`441bdbb2`). The +branch merged `main` (a merge, not a rebase, so the reviewed shas stand) at **`fd1b5a52`**. Nothing +of W1's was deleted or superseded on `main`, so nothing yielded; every W1 change is in the merged +tree as reviewed. + +**Conflicts and how each was resolved:** +- **`editor/CHANGELOG.md`, the one conflict.** The cut renamed the old `[Unreleased]` to + `[0.11.0] - 2026-09-30`, and `main` has no `[Unreleased]`. W1's four bullets now sit under a new + `## [Unreleased]` above `## [0.11.0]`, and `[0.11.0]` is `main`'s, byte for byte. Checked by eye: + none of W1's bullets is inside a released section, and each appears once. +- **Merged without a conflict, both sides kept** (each re-read on the merged tree): + - `common/bin/doctor.ts`. `main` added the source block (release 12 R), the stored-icon line + (release 14 HP), the out-of-process stall check (DS), stagit and its render cache (SG) and the + drive-health line (DT). None of these touches the worker loop, where L7's one change (the + default engine from `paths.whisperBin` / `paths.parakeetBin`) sits unchanged. + - `common/bin/doctor.test.ts`: `main`'s new Paths fields and tests, and W1's `whisperBin` and + `parakeetBin` in the test Paths and its L7 test. + - `editor/app/sites/components/HomepageBuildButtons.tsx`: `main` added one paragraph (release 12's + source-mirror note under the buttons). W1's `useRouter`, `settled()` and `onSettled` prop are + as reviewed. `JobLane.tsx` is unchanged on `main`; the `listed` checkbox (HS) lives in + `SiteForm.tsx`, which W1 does not touch. + - `editor/package.json`: DS's `start` (`UV_THREADPOOL_SIZE=${UV_THREADPOOL_SIZE:-16}`) and W1's + `test` beside it. + - `plans/FACTS.md`: W1's two paragraphs (the Diagnostics cards closed, a queued cancel's sidecar) + land where they did; `main`'s additions are elsewhere and about nothing W1 changed. +- **Checked, nothing to reconcile:** `common/jobs/**` (`jobKinds`, `registry`, `streamCommand`, + `shutdownCancel`) is unchanged on `main` since the fork. DS's `needsMedia` work is in + `buildStats.ts` and the records, not in `jobKinds.ts`. `DiagnosticsStage.tsx` and the two + specs W1 changed (`transcript-source`, `sites-homepage`) are unchanged on `main`; `editor/e2e/helpers.ts` gained HS's `listed`, which no W1 spec uses. + +**Re-gates on the merged tree** (logs `$T/w1-*.log` in the job's `tmp/`): +- **tsc** (all workspaces) clean, 113 s. The first pass failed in `export` only, on a generated + `export/.next/dev/types/validator.ts` naming `app/use-with-ai/page.js`, the page DX removed. The + gitignored `.next/dev/types` of `export` and `editor` were deleted; neither is a source file. + + | Suite | Result | + |---|---| + | common | **2,387/2,387**, 95 s: `main`'s 2,381 plus W1's 6 (registry +1, streamCommand +4, doctor +1) | + | editor unit | **101/101** three ways: the rules' `pnpm exec tsx --test "app/**/*.test.ts"` in `editor/`, W1's `pnpm --filter editor test` from the root, and `pnpm test` in `editor/`. The script runs the rules' glob, so it runs the same files. | + | `test:scripts` | **194 passed, 2 skipped (196)**, as at DS: the `LIVE=1` archive check, and UT's post-build check skipping this worktree's older umtool build | + | mcp | **271/271** | + +- **Build:** the editor's `next build`, with the primary's `transcripts/` linked in and capped at + 5 GB with no swap: 139 s wall (compiled in 44 s, TypeScript 85 s, under a load average of about + 15), max RSS 1,626,484 KB, exit 0; 79 traces, 0 entries under `transcripts/`. The link was + removed after the build, and nothing ran through it. No export, homepage or umtool file changed. +- **e2e** (editor, detached and queued; `export/public` relinked per path first, the three dangling + links `main` no longer makes removed): + + | Run | Specs | Result | + |---|---|---| + | 1 | W1's three (`transcript-source`, `diagnostics-retry-log`, `sites-homepage`); the two grids' (`availability`, `exclude-from-counts`, `retry-bucket`); `/sites` (`sites-crud`, HS's); the `/jobs` list and kind chips (`jobs`, `jobs-filters`); the cancel path (`queues`, `cancel`); `ops-api` (the homepage kinds as verbs) — `$T/w1-specs.txt` | **73 passed, 1 failed, 7.7 min**, no wait for the queue. The failure: `sites-crud.spec.ts:25`, "Saved" not visible in 5 s after **Create site**, the run's first `/sites/new` save. | + | 2 | `transcript-source`, `diagnostics-retry-log`, `sites-homepage`, `sites-crud`, `--repeat-each=3` — `$T/w1-specs-repeat.txt` | **72 passed, 0 failed, 4.6 min**, after 2.6 min in the queue behind W3's suite: `sites-crud.spec.ts:25` passed in each of the three repetitions, and `transcript-source` 9/9. | + + Run 1's failure is the load-timeout class `release-7.md` recorded for `sites-crud.spec.ts:148` + ("Saved" not visible in 5 s, under load), on a spec and a form W1 does not touch; it passed 3 of 3 + in run 2. +- **Numbers tool:** none. + +**The second join: W3 landed first.** `main` moved to `4a186547` (the merge of `r13/build-image`, +slice W3 brought to main) while the gates above ran, so the branch merged `main` again, at +**`bcba6bdf`**. Two conflicts, both resolved by keeping both sides whole: +- **`editor/CHANGELOG.md`:** `[Unreleased]` holds W3's four bullets as `main` has them, then W1's + four. `[0.11.0]` and everything below it are `main`'s. +- **`plans/release-13.md`:** the Record keeps W3's sections as `main` has them (as shipped, W3b, + W3c, its Brought to main) and puts W1's after them, before the Rollout: the merging slice's + sections after `main`'s, as release 15 SS's merge did. `git diff main` on the file adds W1's + lines and removes none. +- **`common/bin/doctor.ts` and `doctor.test.ts` merged with no conflict:** W3's build-image section + (between umtool and source publish) and its probe helpers, and W1's L7 line in the worker loop of + the tools section. W3's `run()` helper passes `buildImage: noEngine`, so W1's L7 test, which calls + it, never asks the machine's container engine. +- `git diff main --stat` after the merge is W1's 19 files and nothing else. + +Re-gates at `bcba6bdf`'s tree (`w1-gates2.log`): **tsc** clean, 87 s; **doctor 19/19** (W3's 18 +and W1's L7 test); **common 2,402/2,402**, 98 s (`main`'s 2,396 plus W1's 6); **editor unit +101/101**. No e2e and no build: what W3 added is `Dockerfile.build`, `common/publish/build.ts`, +the doctor and their tests, which W3's own re-gate built and ran, and none of it is a file W1 +changed or a path W1's specs drive. + +| sha | what | +|---|---| +| `fd1b5a52` | the merge of `main` `7a77536b`; `editor/CHANGELOG.md` resolved as above | +| `2d701ef9` | `plans:` this subsection, to the first join's gates | +| `bcba6bdf` | the merge of `main` `4a186547` (W3); the changelog and the Record kept both sides | +| _this_ | `plans:` the second join and its re-gates | + ## Rollout