Archilyzer · Source

archilyzer

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

commit 64ba30619b3c2a3e300429ee3fb069c747e395c6
parent f69d0f40654b1638e23f986fd73d2737fbdf784e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 02:36:47 -0400

Merge main 54cb4f2c (O4 + O3) into r11/phase-4-s3

Conflicts only in editor/CHANGELOG.md (one [Unreleased]: O4's and O3's
bullets, then O6's) and plans/release-11.md (O4's and O3's records, then
O6's).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/controller/cutRelease.test.ts | 158++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/controller/cutRelease.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Acommon/controller/maybeMissingBuild.test.ts | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/persistKept.ts | 18+++++++++++++++---
Mcommon/controller/persistKeptForceMedia.test.ts | 169++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcommon/lib/availability-server.ts | 16++++++++++++++--
Mcommon/lib/builtExport.test.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/lib/builtExport.ts | 32+++++++++++++++++++++++++++++++-
Mcommon/lib/videoState.test.ts | 30++++++++++++++++++++++++++++--
Mcommon/publish/build.test.ts | 7+++++++
Mcommon/publish/build.ts | 13++++++++++++-
Mcommon/ytdlp/downloadOneManaged.ts | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/ytdlp/forceMedia.test.ts | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/CHANGELOG.md | 5+++++
Aeditor/app/api/ops/build-homepage/route.ts | 33+++++++++++++++++++++++++++++++++
Meditor/app/api/ops/cut-release/route.ts | 32++++++++++++++++----------------
Aeditor/app/api/ops/deploy-homepage/route.ts | 32++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/components/RetryBucketControl.tsx | 26+++++++++++++++++++++-----
Meditor/app/channels/[slug]/components/stages/DownloadStage.tsx | 18+++++++++++++++---
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 17+++++++++++++++--
Aeditor/app/sites/components/HomepageBuildButtons.tsx | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/components/JobLane.tsx | 29+++++++++++++++++++++--------
Meditor/app/sites/lib/cutReleaseAction.ts | 8+++++++-
Aeditor/app/sites/lib/homepageDeployActions.ts | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/page.tsx | 44+++++++++++++++++++++++++++++++++++++-------
Meditor/e2e/cookies-mode.spec.ts | 17+++++++++++++++++
Meditor/e2e/fetch-window.spec.ts | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/e2e/fixtures/bin/fake-ytdlp.mjs | 13+++++++++++++
Meditor/e2e/ops-api.spec.ts | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/e2e/ops-cut-release.spec.ts | 6++++++
Meditor/e2e/persist-youtube-handling.spec.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/sites-homepage.spec.ts | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 3+++
Mplans/FACTS.md | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/homepage-deploy-from-ui.md | 29+++++++++++++++++++++++++++++
Mplans/release-11.md | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/archilyzer-ops.mjs | 12++++++++++++
Mscripts/archilyzer-ops.test.mjs | 17+++++++++++++++++
38 files changed, 2209 insertions(+), 126 deletions(-)

diff --git a/common/controller/cutRelease.test.ts b/common/controller/cutRelease.test.ts @@ -7,6 +7,7 @@ import { execa } from "execa"; import { cutReleaseForWorkspace, cutReleases, + describeCutFailure, describeRelease, resolveVersion, todayISO, @@ -363,12 +364,24 @@ test( root, }).finally(() => chmod(exportDir, 0o755)); assert.equal(outcome.ok, false); - assert.equal(outcome.untouched, undefined); + // Something WAS written (release 11 slice O3; it used to be absent). + assert.equal(outcome.untouched, false); const [first, second] = outcome.results; assert.equal(first.ok && first.committed, true); assert.equal(second.workspace, "export"); assert.match(!second.ok ? second.error : "", /^Could not write .*export\/CHANGELOG\.md: EACCES/); + assert.equal(!second.ok && second.written, undefined); assert.deepEqual(outcome.notAttempted, []); + // The one sentence names the failure AND the cut before it (review L4: + // the route's `error` used to name only the export). + const editorSha = first.ok ? (first.commitSha ?? "").slice(0, 8) : ""; + assert.match( + describeCutFailure(outcome, "all"), + new RegExp( + `^export: Could not write .*export/CHANGELOG\\.md: EACCES[^\\n]*\\. ` + + `Before it, editor was already cut \\(## \\[0\\.9\\.1\\] - ${DATE}, committed ${editorSha}\\)\\.$`, + ), + ); assert.deepEqual(await subjects(root), ["Release editor 0.9.1", "init"]); assert.equal(await read(root, "export"), exportSource); }, @@ -505,3 +518,146 @@ test("resolveVersion maps the keywords and keeps a literal", () => { assert.equal(resolveVersion("next-minor", null), "0.1.0"); assert.equal(resolveVersion(" 2.0.0 ", "0.9.0"), "2.0.0"); }); + +// A COMMIT THAT FAILS AFTER THE WRITE (release 11 slice O3, release 10 slice P +// review L3). The changelog on disk has its new heading; the result used to be +// a bare { ok: false } that nothing could tell from a refusal, so neither the +// form nor the route revalidated the pages that render it. A pre-commit hook +// that refuses stands in for any git failure past the guard. +async function refuseCommits(root: string): Promise<void> { + const hooks = path.join(root, ".git", "hooks"); + await mkdir(hooks, { recursive: true }); + await writeFile( + path.join(hooks, "pre-commit"), + "#!/bin/sh\necho 'hook says no' >&2\nexit 1\n", + ); + await chmod(path.join(hooks, "pre-commit"), 0o755); + // Local config beats a global core.hooksPath, which would skip .git/hooks. + await git(root, "config", "core.hooksPath", hooks); +} + +test("a commit that fails after the write says the file was written (written: true, not untouched)", async () => { + const source = changelog({ pending: ["- x"], latest: "0.9.0" }); + await withRepo({ editor: source }, async (root) => { + await refuseCommits(root); + const result = await cutReleaseForWorkspace({ + workspace: "editor", + version: "next", + commit: true, + date: DATE, + root, + }); + assert.equal(result.ok, false); + assert.equal(!result.ok && result.written, true); + assert.match( + !result.ok ? result.error : "", + /^Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/, + ); + assert.match(await read(root, "editor"), new RegExp(`## \\[0\\.9\\.1\\] - ${DATE}\\n- x`)); + assert.deepEqual(await subjects(root), ["init"]); + }); + // Through cutReleases, a single workspace: not untouched, and the sentence + // is the writer's. + await withRepo({ editor: source }, async (root) => { + await refuseCommits(root); + const outcome = await cutReleases({ + workspace: "editor", + version: "next", + commit: true, + date: DATE, + root, + }); + assert.equal(outcome.ok, false); + assert.equal(outcome.untouched, false); + assert.match( + describeCutFailure(outcome, "editor"), + /^Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/, + ); + }); +}); + +test("all whose first commit fails after its write: stopped, not untouched, the export not tried", async () => { + const exportSource = changelog({ pending: ["- x"], latest: "0.9.0" }); + await withRepo( + { editor: changelog({ pending: ["- e"], latest: "0.9.0" }), export: exportSource }, + async (root) => { + await refuseCommits(root); + const outcome = await cutReleases({ + workspace: "all", + version: "next", + commit: true, + date: DATE, + root, + }); + assert.equal(outcome.ok, false); + assert.equal(outcome.untouched, false); + assert.deepEqual(outcome.notAttempted, ["export"]); + assert.equal(outcome.results.length, 1); + const [editor] = outcome.results; + assert.equal(editor.workspace, "editor"); + assert.equal(!editor.ok && editor.written, true); + assert.match( + describeCutFailure(outcome, "all"), + /^editor: Cut release 0\.9\.1, but the commit failed: [\s\S]*hook says no/, + ); + assert.match(await read(root, "editor"), new RegExp(`## \\[0\\.9\\.1\\] - ${DATE}`)); + assert.equal(await read(root, "export"), exportSource); + assert.deepEqual(await subjects(root), ["init"]); + }, + ); +}); + +test("untouched is true for every refusal and false for a clean cut; a refusal's sentence is the writer's", async () => { + await withRepo({ editor: changelog({ pending: [], latest: "0.9.0" }) }, async (root) => { + const refused = await cutReleases({ + workspace: "editor", + version: "next", + commit: false, + date: DATE, + root, + }); + assert.equal(refused.untouched, true); + assert.equal( + describeCutFailure(refused, "editor"), + "Nothing pending to release under [Unreleased].", + ); + }); + await withRepo({ editor: changelog({ pending: ["- x"], latest: "0.9.0" }) }, async (root) => { + const cut = await cutReleases({ + workspace: "editor", + version: "next", + commit: false, + date: DATE, + root, + }); + assert.equal(cut.ok, true); + assert.equal(cut.untouched, false); + }); +}); + +test( + "all whose FIRST write fails wrote nothing: untouched, the export not tried", + { skip: isRoot ? "root ignores directory permissions" : false }, + async () => { + const editorSource = changelog({ pending: ["- e"], latest: "0.9.0" }); + const exportSource = changelog({ pending: ["- x"], latest: "0.9.0" }); + await withRepo({ editor: editorSource, export: exportSource }, async (root) => { + const editorDir = path.join(root, "editor"); + await chmod(editorDir, 0o555); + const outcome = await cutReleases({ + workspace: "all", + version: "next", + commit: false, + date: DATE, + root, + }).finally(() => chmod(editorDir, 0o755)); + assert.equal(outcome.ok, false); + assert.equal(outcome.untouched, true); + assert.deepEqual(outcome.notAttempted, ["export"]); + assert.match(describeCutFailure(outcome, "all"), /^editor: Could not write .*: EACCES/); + assert.doesNotMatch(describeCutFailure(outcome, "all"), /Before it/); + assert.equal(await read(root, "editor"), editorSource); + assert.equal(await read(root, "export"), exportSource); + }); + }, +); diff --git a/common/controller/cutRelease.ts b/common/controller/cutRelease.ts @@ -128,7 +128,16 @@ export type CutReleaseResult = // HEAD after the release commit; only when `committed`. commitSha?: string; } - | { ok: false; workspace: ReleaseWorkspace; error: string }; + | { + ok: false; + workspace: ReleaseWorkspace; + error: string; + // THE FILE WAS WRITTEN; only the commit after it failed (release 11 + // slice O3, release 10 review L3). The changelog on disk now carries the + // new heading, so a caller that revalidates pages on a cut must do so + // here too. Absent on every other failure: nothing was written. + written?: true; + }; export type CutReleaseOptions = { workspace: ReleaseWorkspace; @@ -237,7 +246,12 @@ async function applyCut( `Release ${workspace} ${version}`, ); if (!result.ok) { - return fail(`Cut release ${version}, but the commit failed: ${result.error}`); + return { + ok: false, + workspace, + error: `Cut release ${version}, but the commit failed: ${result.error}`, + written: true, + }; } const sha = await headSha(repo.root); return { @@ -284,10 +298,20 @@ export type CutReleasesOutcome = { results: CutReleaseResult[]; // The workspaces a failure stopped before they were tried. notAttempted: ReleaseWorkspace[]; - // `all` refused before writing anything: neither changelog was touched. - untouched?: true; + // No changelog was written. True for every refusal, `all`'s preflight + // included; false once any file was written, a cut whose commit then failed + // (`written`) included. cutReleases ALWAYS sets it (release 11 slice O3, so + // a caller can tell a refusal from a cut stopped half-way — review L4); it + // is optional only so a hand-built outcome (the CLI's display tests) need + // not carry it, and there absent reads as false. + untouched?: boolean; }; +// Whether any result of a run wrote its changelog. +function nothingWritten(results: readonly CutReleaseResult[]): boolean { + return !results.some((r) => r.ok || r.written === true); +} + /** * Cut `editor`, `export`, or `all` (editor then export, with the SAME version: * a keyword resolves against the HIGHER of the two latest headings, so neither @@ -312,6 +336,7 @@ export async function cutReleases( version: result.ok ? result.version : null, results: [result], notAttempted: [], + untouched: nothingWritten([result]), }; } const workspaces: ReleaseWorkspace[] = [...RELEASE_WORKSPACES]; @@ -365,16 +390,48 @@ export async function cutReleases( for (const cut of plans) { const result = await applyCut(repo, cut, opts.commit); if (!result.ok) { + const results = [...done, result]; return { ok: false, version, - results: [...done, result], + results, notAttempted: workspaces.slice(done.length + 1), + untouched: nothingWritten(results), }; } done.push(result); } - return { ok: true, version, results: done, notAttempted: [] }; + return { ok: true, version, results: done, notAttempted: [], untouched: false }; +} + +/** + * The one sentence for a cut that did not fully happen — the ops route's + * top-level `error` (release 11 slice O3, release 10 review L4). The failing + * workspace's refusal (prefixed with its name for `all`), then whatever was + * already cut before it: an `all` stopped half-way by I/O or git has cut and + * possibly committed the editor, and a caller that reads only `error` must not + * be told only about the export. + */ +export function describeCutFailure( + outcome: CutReleasesOutcome, + target: ReleaseTarget, +): string { + const failure = outcome.results.find((r) => !r.ok); + if (!failure || failure.ok) return "cut failed"; + const head = target === "all" ? `${failure.workspace}: ${failure.error}` : failure.error; + const done = outcome.results.flatMap((r) => + r.ok + ? [ + `${r.workspace} was already cut (${r.heading}, ${ + r.committed + ? `committed${r.commitSha ? ` ${r.commitSha.slice(0, 8)}` : ""}` + : "not committed" + })`, + ] + : [], + ); + if (done.length === 0) return head; + return `${head.replace(/\.$/, "")}. Before it, ${done.join("; ")}.`; } export type ReleaseSummary = diff --git a/common/controller/maybeMissingBuild.test.ts b/common/controller/maybeMissingBuild.test.ts @@ -0,0 +1,149 @@ +// Integration: the "Missing?" presence badge, through the REAL buildIndex, over +// a temp corpus — the published half of resolveMaybeMissingState. +// +// The export draws the badge from a summary's `state` (export e2e +// availability-state.spec.ts pins state → badge with mocked summaries). What +// decides that `state` for a video that fell out of its channel's listing is +// buildIndex's maybe-missing overlay, and that is what this file pins: the +// site's summaries page, which is where the search results and the Availability +// filter read `state` (the shared transcript pages carry cues, not state). +// +// Release 11 slice O3: a confirm probe made after the scan that FAILED (an +// `error` availability) used to publish the video as available — no badge — +// because stateFromAvailability folds `error` into `available`. It now stays +// maybe_missing, like a video never probed. +// +// Run with: node_modules/.bin/tsx --test common/controller/maybeMissingBuild.test.ts + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +// getPaths() is lazy and cached, and nothing above calls it at import time, so +// pointing the whole path graph at a temp root here isolates this file's +// process from the real corpus (the curatedTagsBuild.test.ts pattern). +const ROOT = mkdtempSync(path.join(tmpdir(), "maybe-missing-build-")); +process.env.TRANSCRIPTS_DIR = path.join(ROOT, "transcripts"); +process.env.EXPORT_PUBLIC_DIR = path.join(ROOT, "public"); +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); + +const { getPaths } = await import("../lib/paths"); +const { buildIndex } = await import("./buildIndex"); + +const paths = getPaths(); +const CHANNEL = "test-channel"; +const SITE = "testsite"; + +const SCAN_AT = "2026-08-01T12:00:00.000Z"; +const BEFORE_SCAN = "2026-07-20T00:00:00.000Z"; +const AFTER_SCAN = "2026-08-02T00:00:00.000Z"; + +// One video per outcome of the overlay. Only LISTED is in the channel's +// listing; every other id is in maybe-missing.json. +const LISTED = "vid-listed"; +const NEVER_PROBED = "vid-never-probed"; +const ERROR_AFTER = "vid-error-after"; +const PUBLIC_AFTER = "vid-public-after"; +const PUBLIC_BEFORE = "vid-public-before"; +const DELETED = "vid-deleted"; + +function writeJson(file: string, value: unknown): void { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value, null, 2)); +} + +function videoDir(id: string): string { + return path.join(paths.channelsDir, CHANNEL, "data", id); +} + +function seedCorpus(): void { + writeFileSync(paths.settingsFile, JSON.stringify({})); + writeJson(path.join(paths.channelsDir, CHANNEL, "config.json"), { + handling: "youtube", + name: "Test Channel", + url: "https://www.youtube.com/@example/videos", + }); + const ids = [LISTED, NEVER_PROBED, ERROR_AFTER, PUBLIC_AFTER, PUBLIC_BEFORE, DELETED]; + ids.forEach((id, i) => { + writeJson(path.join(videoDir(id), "metadata.info.json"), { + id, + title: `Video ${id}`, + channel: "Test Channel", + upload_date: `2026010${i + 1}`, + duration: 120, + description: "fixture", + webpage_url: `https://www.youtube.com/watch?v=${id}`, + extractor_key: "Youtube", + }); + writeFileSync( + path.join(videoDir(id), "transcript.en.vtt"), + "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nA line of transcript.\n", + ); + }); + const availability = (id: string, availability: string, checkedAt: string) => + writeJson(path.join(videoDir(id), "availability.json"), { checkedAt, availability }); + availability(ERROR_AFTER, "error", AFTER_SCAN); + availability(PUBLIC_AFTER, "public", AFTER_SCAN); + availability(PUBLIC_BEFORE, "public", BEFORE_SCAN); + availability(DELETED, "deleted", BEFORE_SCAN); + writeJson(path.join(paths.channelsDir, CHANNEL, "maybe-missing.json"), { + checkedAt: SCAN_AT, + freshPlaylistCount: 1, + ids: [NEVER_PROBED, ERROR_AFTER, PUBLIC_AFTER, PUBLIC_BEFORE, DELETED], + }); + writeJson(path.join(paths.sitesDir, SITE, "site.json"), { + siteId: SITE, + siteTitle: "Test Site", + siteDescription: "fixture", + headerTitle: "Test Site", + homeTagline: "", + socialLinks: [], + groups: [{ id: "default", name: "All channels", selectedByDefault: true }], + defaultGroupId: "default", + channels: [{ slug: CHANNEL, groupId: "default" }], + }); +} + +type Published = { id: string; state?: string; isDeleted?: boolean }; + +function readPage(file: string): Published[] { + return JSON.parse(readFileSync(file, "utf8")) as Published[]; +} + +const summariesPage = () => + readPage(path.join(paths.exportSitesIndexDir, SITE, "summaries", "page-0000.json")); + +function stateOf(page: Published[], id: string): string { + const record = page.find((r) => r.id === id); + assert.ok(record, `${id} is published`); + // The build omits `state` for an available video (the badge's absence). + return record.state ?? "available"; +} + +test("the published state of each video that left the listing, as the badge reads it", async () => { + seedCorpus(); + const log: string[] = []; + const res = await buildIndex({ paths, onLog: (s) => log.push(s) }); + assert.equal(res.totalCount, 6); + + const expected: Record<string, string> = { + [LISTED]: "available", + [NEVER_PROBED]: "maybe_missing", + // THE CASE: the confirm probe failed after the scan. No evidence either + // way, so the badge stays. + [ERROR_AFTER]: "maybe_missing", + [PUBLIC_AFTER]: "available", + [PUBLIC_BEFORE]: "maybe_missing", + [DELETED]: "deleted", + }; + const page = summariesPage(); + const got = Object.fromEntries(Object.keys(expected).map((id) => [id, stateOf(page, id)])); + assert.deepEqual(got, expected); + // The build's own count line names the three unconfirmed videos. + assert.ok( + log.some((l) => l.includes("Availability: 3 video(s) unconfirmed-missing from a channel listing.")), + log.filter((l) => l.startsWith("Availability")).join("\n") || "no Availability line", + ); +}); diff --git a/common/controller/persistKept.ts b/common/controller/persistKept.ts @@ -7,7 +7,10 @@ import { resolveCookiePolicy } from "../lib/cookiePolicy"; import { isSavedVideo } from "../lib/savedVideo-server"; import { computeKeptVideoIds } from "./keptVideos"; import { findVideoSourceUrl } from "./undownloadedVideos"; -import { downloadOneManaged } from "../ytdlp/downloadOneManaged"; +import { + downloadOneManaged, + sourceFetchFailure, +} from "../ytdlp/downloadOneManaged"; // Bulk "persist kept now" pass (Phase 5). Ensures every video currently in the // channel's keep-latest window has its source container saved to the store, for @@ -119,7 +122,7 @@ export async function persistKept({ } log(` ${videoId}: re-fetching source container to persist…`); try { - await downloadOneManaged({ + const record = await downloadOneManaged({ channelSlug, channelConfig, paths, @@ -136,7 +139,16 @@ export async function persistKept({ // captions, which is every kept one (release 10 slice N). forceMedia: true, }); - result.persisted += 1; + // A download that RETURNED is not a persisted source (release 11 slice + // O3, review low 2): a failed forced media pass leaves the download `ok` + // (the transcript is fine) with only its n: 3 attempt failed. + const failure = sourceFetchFailure(record); + if (failure) { + result.failed += 1; + log(` ${videoId}: persist failed — ${failure}`); + } else { + result.persisted += 1; + } } catch (e) { result.failed += 1; log(` ${videoId}: persist failed — ${(e as Error).message}`); diff --git a/common/controller/persistKeptForceMedia.test.ts b/common/controller/persistKeptForceMedia.test.ts @@ -47,6 +47,11 @@ if (has("--skip-download") && has("--write-auto-subs")) { process.exit(0); } if ((arg("-o") || "").includes("source-media")) { + if (fs.existsSync(path.join(root, "media-fail"))) { + fs.writeFileSync(path.join(dir, "source-media.f137.mp4.part"), "half a container"); + console.error("ERROR: [download] Got error: HTTP Error 403: Forbidden"); + process.exit(1); + } fs.writeFileSync(path.join(dir, "source-media.mp4"), "container bytes for " + id); console.log("DLOM_ARCHIVE youtube " + id); process.exit(0); @@ -55,83 +60,123 @@ console.error("fake: unknown invocation " + argv.join(" ")); process.exit(2); `; -test("persistKept forces the media pass: a kept youtube-handling video with a transcript is persisted, with no audio", async () => { - const root = await mkdtemp(path.join(tmpdir(), "persist-kept-force-")); - try { - // Disk floor off, so the per-item gate cannot turn this into a skip on a - // full disk. Set before persistKept's first getSettings() → getPaths(). - const settingsFile = path.join(root, "settings.json"); - await writeFile(settingsFile, JSON.stringify({ minFreeDiskGB: 0 })); - process.env.SETTINGS_FILE = settingsFile; - process.env.TRANSCRIPTS_DIR = root; - process.env.FAKE_ROOT = root; +// ONE settings file for the whole process: persistKept reads settings through +// getPaths(), which caches the first SETTINGS_FILE it sees, so a per-test file +// would be gone by the second test. Disk floor off, so the per-item gate cannot +// turn a run into a skip on a full disk. +const SETTINGS_ROOT = await mkdtemp(path.join(tmpdir(), "persist-kept-settings-")); +const SETTINGS_FILE = path.join(SETTINGS_ROOT, "settings.json"); +await writeFile(SETTINGS_FILE, JSON.stringify({ minFreeDiskGB: 0 })); +process.env.SETTINGS_FILE = SETTINGS_FILE; +process.env.TRANSCRIPTS_DIR = SETTINGS_ROOT; - const ytdlp = path.join(root, "fake-ytdlp.cjs"); - await writeFile(ytdlp, FAKE_YTDLP); - await chmod(ytdlp, 0o755); - const meta = { - id: ID, - title: "A kept video", - upload_date: "20260101", - duration: 60, - webpage_url: VIDEO, - subtitles: {}, - automatic_captions: { en: [{ url: "https://example/cap" }] }, - }; - await writeFile(path.join(root, "meta.json"), JSON.stringify(meta)); +const TRANSCRIPT = '{"transcription":[{"text":"kept"}]}'; - const paths = { - transcriptsDir: root, - channelsDir: path.join(root, "channels"), - savedVideosDir: path.join(root, "saved-videos"), - ytdlpBin: ytdlp, - ffmpegBin: path.join(root, "no-ffmpeg"), - ffprobeBin: path.join(root, "no-ffprobe"), - } as Paths; - const videoDir = path.join(paths.channelsDir, "c", "data", ID); - await mkdir(videoDir, { recursive: true }); - await writeFile(path.join(videoDir, "metadata.info.json"), JSON.stringify(meta)); - const transcript = '{"transcription":[{"text":"kept"}]}'; - await writeFile(path.join(videoDir, "transcript.json"), transcript); +// A youtube-handling channel keeping its latest video, which has a transcript; +// persistKept over it. `mediaFail`: the media pass dies part-way with a 403. +async function runPersistKept(opts: { mediaFail?: boolean } = {}) { + const root = await mkdtemp(path.join(tmpdir(), "persist-kept-force-")); + process.env.FAKE_ROOT = root; + const ytdlp = path.join(root, "fake-ytdlp.cjs"); + await writeFile(ytdlp, FAKE_YTDLP); + await chmod(ytdlp, 0o755); + if (opts.mediaFail) await writeFile(path.join(root, "media-fail"), ""); + const meta = { + id: ID, + title: "A kept video", + upload_date: "20260101", + duration: 60, + webpage_url: VIDEO, + subtitles: {}, + automatic_captions: { en: [{ url: "https://example/cap" }] }, + }; + await writeFile(path.join(root, "meta.json"), JSON.stringify(meta)); + + const paths = { + transcriptsDir: root, + channelsDir: path.join(root, "channels"), + savedVideosDir: path.join(root, "saved-videos"), + ytdlpBin: ytdlp, + ffmpegBin: path.join(root, "no-ffmpeg"), + ffprobeBin: path.join(root, "no-ffprobe"), + } as Paths; + const videoDir = path.join(paths.channelsDir, "c", "data", ID); + await mkdir(videoDir, { recursive: true }); + await writeFile(path.join(videoDir, "metadata.info.json"), JSON.stringify(meta)); + await writeFile(path.join(videoDir, "transcript.json"), TRANSCRIPT); - let log = ""; - const result = await persistKept({ - paths, - channelSlug: "c", - channelConfig: { - handling: "youtube", - url: "https://www.youtube.com/@c/videos", - keepLatest: 1, - } as ChannelConfig, - onLog: (s) => { - log += s; - }, - }); + let log = ""; + const result = await persistKept({ + paths, + channelSlug: "c", + channelConfig: { + handling: "youtube", + url: "https://www.youtube.com/@c/videos", + keepLatest: 1, + } as ChannelConfig, + onLog: (s) => { + log += s; + }, + }); + const argvs = (await readFile(path.join(root, "argv.log"), "utf8")) + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l) as string[]); + return { + result, + log, + argvs, + videoDir, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} - assert.equal(result.kept, 1); - assert.equal(result.persisted, 1); - assert.match(log, /forceMedia: downloading the source although a transcript is on disk/); +test("persistKept forces the media pass: a kept youtube-handling video with a transcript is persisted, with no audio", async () => { + const r = await runPersistKept(); + try { + assert.equal(r.result.kept, 1); + assert.equal(r.result.persisted, 1); + assert.match(r.log, /forceMedia: downloading the source although a transcript is on disk/); // The media pass ran: three spawns, the last without --skip-download. - const argvs = (await readFile(path.join(root, "argv.log"), "utf8")) - .split("\n") - .filter(Boolean) - .map((l) => JSON.parse(l) as string[]); - assert.equal(argvs.length, 3); - assert.ok(!argvs[2].includes("--skip-download")); + assert.equal(r.argvs.length, 3); + assert.ok(!r.argvs[2].includes("--skip-download")); // Persisted, and nothing else: the pointer is there, the container is in // the store, no audio beside the transcript, the transcript untouched. const pointer = JSON.parse( - await readFile(path.join(videoDir, "saved-video.json"), "utf8"), + await readFile(path.join(r.videoDir, "saved-video.json"), "utf8"), ) as { dir: string; file: string; keepReason?: string }; assert.equal(pointer.keepReason, "override"); assert.equal( await readFile(path.join(pointer.dir, pointer.file), "utf8"), `container bytes for ${ID}`, ); - const files = await readdir(videoDir); + const files = await readdir(r.videoDir); assert.deepEqual(files.filter((f) => /^(audio|source-media)\./.test(f)), []); - assert.equal(await readFile(path.join(videoDir, "transcript.json"), "utf8"), transcript); + assert.equal(await readFile(path.join(r.videoDir, "transcript.json"), "utf8"), TRANSCRIPT); + } finally { + await r.cleanup(); + } +}); + +// A RETURNED DOWNLOAD IS NOT A PERSISTED SOURCE (release 11 slice O3, review +// low 2). A forced media pass that fails over a transcript leaves the download +// `ok`, so counting every return as `persisted` claimed a container that is +// not there. +test("persistKept counts a failed media pass as failed, not persisted", async () => { + const r = await runPersistKept({ mediaFail: true }); + try { + assert.equal(r.result.kept, 1); + assert.equal(r.result.persisted, 0); + assert.equal(r.result.failed, 1); + assert.match( + r.log, + new RegExp(`${ID}: persist failed — ERROR: \\[download\\] Got error: HTTP Error 403: Forbidden`), + ); + assert.match(r.log, /Persist kept: 0 persisted, 0 already saved, 1 failed/); + const files = await readdir(r.videoDir); + assert.ok(!files.includes("saved-video.json")); + assert.equal(await readFile(path.join(r.videoDir, "transcript.json"), "utf8"), TRANSCRIPT); } finally { - await rm(root, { recursive: true, force: true }); + await r.cleanup(); } }); diff --git a/common/lib/availability-server.ts b/common/lib/availability-server.ts @@ -140,8 +140,19 @@ export async function resolveEffectiveAvailability( // anyway (a truncated or oddly paginated playlist fetch) is resolved, and // must stop being flagged. // -// Anything else — never probed, or last probed before the scan — is -// `maybe_missing`: we know it left the listing, not yet why. +// Anything else — never probed, last probed before the scan, or probed after +// it with an `error` — is `maybe_missing`: we know it left the listing, not +// yet why. +// +// AN `error` PROBE ANSWERS NOTHING, however recent (release 11 slice O3). It is +// a 403, a network failure, YouTube's bot check or the one rate-limited probe of +// a blocked run: the probe never saw the video. `stateFromAvailability` folds +// it into `available` because it is no evidence the video is GONE — and by the +// same token it is no evidence the video is THERE. Read as a confirmation, it +// cleared the "Missing?" badge of exactly the videos whose confirm probe failed. +// (`needs_auth` is different: an age gate is the video answering.) Display +// only — this feeds the published presence state via buildIndex; the clean gate +// reads resolveEffectiveAvailability, which this does not touch. export async function resolveMaybeMissingState( videoDir: string, scannedAtMs: number, @@ -150,6 +161,7 @@ export async function resolveMaybeMissingState( if (!record) return "maybe_missing"; const confirmed = stateFromAvailability(record.availability); if (confirmed !== "available") return confirmed; + if (record.availability === "error") return "maybe_missing"; const checkedAtMs = Date.parse(record.checkedAt); if (Number.isFinite(checkedAtMs) && checkedAtMs >= scannedAtMs) { return "available"; diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -1,9 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { builtHubProblem, builtSiteIdIn, builtSiteProblem } from "./builtExport"; +import { + builtHomepageAt, + builtHomepageProblem, + builtHubProblem, + builtSiteIdIn, + builtSiteProblem, +} from "./builtExport"; function tempOut(siteJson?: string): { dir: string; cleanup: () => void } { const dir = mkdtempSync(path.join(tmpdir(), "built-export-")); @@ -113,3 +119,44 @@ test("builtHubProblem accepts only a hub bundle", () => { none.cleanup(); } }); + +// The homepage package builds into its own homepage/out, which nothing else +// writes, so "built" is one question: is there an index.html? The deploy-only +// action refuses before any job on exactly the file deployHomepage checks +// inside it. +test("builtHomepageProblem refuses an out dir with no index.html, and only that", () => { + const built = tempOut(); + const empty = tempOut(); + try { + writeFileSync(path.join(built.dir, "index.html"), "<!doctype html>"); + assert.equal(builtHomepageProblem(built.dir), null); + + const problem = "homepage/out holds no build — build the homepage first"; + assert.equal(builtHomepageProblem(empty.dir), problem); + // No directory at all is the same answer, not a throw. + assert.equal(builtHomepageProblem(path.join(tmpdir(), "no-such-homepage-out-ever")), problem); + // A stray file that is not the page is still no build. + writeFileSync(path.join(empty.dir, "404.html"), "<!doctype html>"); + assert.equal(builtHomepageProblem(empty.dir), problem); + } finally { + built.cleanup(); + empty.cleanup(); + } +}); + +test("builtHomepageAt is index.html's mtime, and null with no build", () => { + const built = tempOut(); + const empty = tempOut(); + try { + const index = path.join(built.dir, "index.html"); + writeFileSync(index, "<!doctype html>"); + const when = new Date("2026-09-28T00:53:00Z"); + utimesSync(index, when, when); + assert.equal(builtHomepageAt(built.dir), when.getTime()); + assert.equal(builtHomepageAt(empty.dir), null); + assert.equal(builtHomepageAt(path.join(tmpdir(), "no-such-homepage-out-ever")), null); + } finally { + built.cleanup(); + empty.cleanup(); + } +}); diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -13,7 +13,7 @@ // public/ into out/. So the check is a file read, and it is cheap enough to do // before every deploy. -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; /** @@ -80,3 +80,33 @@ export function builtHubProblem(outDir: string): string | null { } return null; } + +/** + * Why `outDir` — the homepage package's `homepage/out` — may not be deployed as + * the homepage, as one sentence, or null when it holds a build. + * + * Unlike export/out nothing else ever builds into homepage/out, so the only + * question is whether a build is there at all. `index.html` is the file + * deployHomepage checks inside its job, so the refusal an operator gets before + * the job and the one the job would give agree on what "built" means. + */ +export function builtHomepageProblem(outDir: string): string | null { + if (!existsSync(path.join(outDir, "index.html"))) { + return "homepage/out holds no build — build the homepage first"; + } + return null; +} + +/** + * When the homepage build in `outDir` was made (its `index.html`'s mtime, in + * ms), or null when builtHomepageProblem would refuse it. What `/sites` shows + * as "built <when>" beside Deploy homepage, so the operator sees how old the + * bundle a deploy-only would ship is. + */ +export function builtHomepageAt(outDir: string): number | null { + try { + return statSync(path.join(outDir, "index.html")).mtimeMs; + } catch { + return null; + } +} diff --git a/common/lib/videoState.test.ts b/common/lib/videoState.test.ts @@ -99,8 +99,34 @@ test("in the set + public, last probed BEFORE the scan → maybe_missing", async }); }); -// needs_auth/error are not evidence the video left its channel, so they behave -// exactly like public: still absent from the listing, still unconfirmed. +// A probe made AFTER the scan that FAILED saw nothing: a 403, a network error, +// the bot check, the one rate-limited probe of a blocked run. It must not read +// as a confirmation the way a public re-probe does (release 11 slice O3; it +// used to, through stateFromAvailability's `error` → available fold). +test("in the set + error, probed AFTER the scan → still maybe_missing", async () => { + await withVideoDir(async (videoDir) => { + await seedAvailability(videoDir, "error", AFTER_SCAN); + assert.equal( + await resolveMaybeMissingState(videoDir, SCAN_AT_MS), + "maybe_missing", + ); + }); +}); + +// An age gate is the video answering: a needs_auth re-probe after the scan +// confirms it is there, exactly like public. +test("in the set + needs_auth, probed AFTER the scan → available", async () => { + await withVideoDir(async (videoDir) => { + await seedAvailability(videoDir, "needs_auth", AFTER_SCAN); + assert.equal( + await resolveMaybeMissingState(videoDir, SCAN_AT_MS), + "available", + ); + }); +}); + +// needs_auth/error are not evidence the video left its channel, so before the +// scan they behave exactly like public: still absent, still unconfirmed. for (const availability of ["needs_auth", "error"] as const) { test(`in the set + ${availability} before the scan → maybe_missing`, async () => { await withVideoDir(async (videoDir) => { diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts @@ -7,6 +7,7 @@ import { buildSiteSteps, hubProjectProblem, homepageDeployArgs, + homepageOutDir, deployHomepage, dockerSiteOutDir, dockerSiteStagingDir, @@ -136,6 +137,12 @@ test("homepageDeployArgs: production is branch main; a preview is its own branch assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " r8-home "), [...base, "--branch", "r8-home"]); }); +// The editor's homepage actions judge "is there a build?" on this path before +// any job, so it must be the directory deployHomepage ships. +test("homepageOutDir is homepage/out of the checkout", () => { + assert.equal(homepageOutDir({ monorepoRoot: "/repo" } as Paths), "/repo/homepage/out"); +}); + test("deployHomepage refuses a bad preview branch before it looks for a build", async () => { const noBuild = { monorepoRoot: "/nonexistent-repo" } as Paths; await assert.rejects( diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -939,6 +939,16 @@ function homepageDir(paths: Paths): string { return path.join(paths.monorepoRoot, "homepage"); } +/** + * Where buildHomepage writes and deployHomepage ships: `homepage/out` of this + * checkout. Exported for the editor's homepage actions, which refuse a + * deploy-only before any job when nothing is built there + * (builtHomepageProblem) and show when it was built. + */ +export function homepageOutDir(paths: Paths): string { + return path.join(homepageDir(paths), "out"); +} + function homepageEnv(paths: Paths): NodeJS.ProcessEnv { return { ...process.env, @@ -1006,7 +1016,8 @@ export async function deployHomepage( if (problem) throw new Error(problem); } const branch = opts.previewBranch?.trim() || undefined; - const outDir = path.join(homepageDir(paths), "out"); + // The directory the editor's actions judged "built" (builtHomepageProblem). + const outDir = homepageOutDir(paths); if (!existsSync(path.join(outDir, "index.html"))) { throw new Error("homepage/out holds no build — run archilyzer build homepage first"); } diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { appendFile, mkdir, readdir, readFile, rm } from "node:fs/promises"; +import { appendFile, mkdir, readdir, readFile, rm, stat } from "node:fs/promises"; import { createWriteStream, type Dirent, type WriteStream } from "node:fs"; import { execa } from "execa"; import { @@ -38,6 +38,7 @@ import { type DownloadOutcomeStatus, } from "../lib/downloadOutcome"; import { writeDownloadOutcome } from "../lib/downloadOutcome-server"; +import { formatBytes } from "../lib/format"; import { recordAvailability } from "../lib/availability-server"; import { withMetadataHistory } from "../lib/metadataHistory-server"; import { @@ -517,6 +518,46 @@ function attemptSucceeded(exitCode: number | null): boolean { return exitCode === 0 || exitCode === 101; } +// DID A DOWNLOAD THAT WAS ASKED FOR THE SOURCE GET IT? (release 11 slice O3.) +// For "Persist source video" and the whole-recording fetch: null when nothing +// failed, else yt-dlp's reason. Two ways to fail: the download failed outright +// (the status says so), or the forced media pass over a transcript failed — +// which leaves the status as the subtitle pass made it, so only the last +// attempt (the n: 3 media pass) says so. Reading the status alone ended those +// jobs `done` with no file. +// +// WHEN THE LAST ATTEMPT IS THE MEDIA PASS, IT ALONE ANSWERS (review low 1). That +// pass IS the source fetch; a later step's status is not about the source. A +// fallback whose container came down and was persisted, and whose inline +// whisper then failed, ends the download `failed` — and reading the status +// first called that source "not downloaded". +export function sourceFetchFailure(record: DownloadOutcomeRecord): string | null { + const last = record.attempts.at(-1); + const reason = (fallback: string) => last?.error?.trim() || fallback; + if (last?.kind === "no-subs-fallback") { + return attemptSucceeded(last.ytdlpExitCode) + ? null + : reason(`yt-dlp exited ${last.ytdlpExitCode ?? "without a code"}`); + } + if (record.status === "failed" || record.status === "failed-corrupt-source") { + return reason(`the download ended ${record.status}`); + } + return null; +} + +// yt-dlp's partial source container(s) in a video dir — `source-media.<fmt>.part` +// and its fragments — with their sizes, for the failed-media log line. +async function sourceMediaPartials(videoDir: string): Promise<string[]> { + const entries = await readdir(videoDir).catch(() => [] as string[]); + const out: string[] = []; + for (const name of entries.sort()) { + if (!name.startsWith("source-media.") || !/\.part(?:-Frag\d+)?$/.test(name)) continue; + const st = await stat(path.join(videoDir, name)).catch(() => null); + out.push(st ? `${name} (${formatBytes(st.size)})` : name); + } + return out; +} + async function hasAnyTranscriptOnDisk(videoDir: string): Promise<boolean> { const entries = await readdir(videoDir).catch(() => [] as string[]); return entries.some((e) => { @@ -1467,6 +1508,26 @@ async function runManagedDownload( // fellBackToTranscribe flag distinguishes from a normal "ok" // so the UI and downstream jobs can tell what happened. } + } else if (keepTranscript) { + // THE MEDIA FAILED, THE DOWNLOAD DID NOT (release 11 slice O3). A + // persist-only pass over a transcript is an extra the caller asked for, + // not the download: the subtitle pass succeeded and the transcript is + // on disk, so the status stays the subtitle pass's and only the failed + // media attempt is recorded (n: 3 above, with its error). Marking the + // whole download failed put "Download failed" on a video whose + // transcript is fine. A caller that asked for the source reads the + // attempt through sourceFetchFailure. yt-dlp's partial container is + // left where it is — a retry resumes it — and named here, because + // nothing else surfaces a source-media partial. + const partials = await sourceMediaPartials(videoDir); + opts.onLog( + `forceMedia: the source download failed (yt-dlp exit ${fallbackRes.exitCode}); ` + + `the transcript on disk is untouched and the download stays ${status}.` + + (partials.length + ? ` Left for a retry to resume: ${partials.join(", ")}.` + : "") + + "\n", + ); } else { status = "failed"; lastSucceeded = false; diff --git a/common/ytdlp/forceMedia.test.ts b/common/ytdlp/forceMedia.test.ts @@ -13,7 +13,12 @@ import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import type { ChannelConfig } from "../lib/channelConfig"; -import { downloadOneManaged, type ManagedDownloadOpts } from "./downloadOneManaged"; +import { + downloadOneManaged, + sourceFetchFailure, + type ManagedDownloadOpts, +} from "./downloadOneManaged"; +import type { DownloadOutcomeRecord } from "../lib/downloadOutcome"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test ytdlp/forceMedia.test.ts @@ -56,6 +61,12 @@ if (has("--skip-download") && has("--write-auto-subs")) { process.exit(0); } if ((arg("-o") || "").includes("source-media")) { + if (fs.existsSync(path.join(root, "media-fail"))) { + // A media pass that dies mid-download: the partial yt-dlp leaves, then a 403. + fs.writeFileSync(path.join(dir, "source-media.f137.mp4.part"), "half a container"); + console.error("ERROR: [download] Got error: HTTP Error 403: Forbidden"); + process.exit(1); + } fs.writeFileSync(path.join(dir, "source-media.mp4"), "container bytes for " + id); console.log("DLOM_ARCHIVE youtube " + id); process.exit(0); @@ -77,6 +88,8 @@ type Run = { videoDir: string; storeDir: string; files: string[]; + // The channel's download archive after the run. + archive: string; }; async function runWith(opts: { @@ -88,6 +101,9 @@ async function runWith(opts: { priorMeta?: Record<string, unknown>; config?: Partial<ChannelConfig>; managed?: Partial<ManagedDownloadOpts>; + // The media pass (the source-media output) fails with a 403 after writing a + // partial. + mediaFail?: boolean; }): Promise<Run & { cleanup: () => Promise<void> }> { const root = await mkdtemp(path.join(tmpdir(), "force-media-")); const ytdlp = path.join(root, "fake-ytdlp.cjs"); @@ -97,6 +113,7 @@ async function runWith(opts: { await chmod(ytdlp, 0o755); await chmod(ffmpeg, 0o755); await writeFile(path.join(root, "meta.json"), JSON.stringify(opts.meta)); + if (opts.mediaFail) await writeFile(path.join(root, "media-fail"), ""); process.env.FAKE_ROOT = root; const paths = { transcriptsDir: root, @@ -144,6 +161,7 @@ async function runWith(opts: { videoDir, storeDir: path.join(paths.savedVideosDir, "c", ID), files: (await readdir(videoDir)).sort(), + archive: await readFile(path.join(paths.channelsDir, "c", "archive"), "utf8").catch(() => ""), cleanup: () => rm(root, { recursive: true, force: true }), }; } @@ -300,3 +318,96 @@ test("the prefetch's rewrite of metadata.info.json lands in metadata.history.jso await r.cleanup(); } }); + +// A FAILED MEDIA PASS OVER A TRANSCRIPT IS NOT A FAILED DOWNLOAD (release 11 +// slice O3; release 10 slice N's review L2). The subtitle pass succeeded and the +// transcript is on disk; the extra the caller asked for — the source — failed. +// The status stays the subtitle pass's (the video page said "Download failed" +// on a video whose transcript is fine), the failed attempt is recorded, and the +// caller that asked for the source learns it through sourceFetchFailure. +test("forceMedia over a transcript whose media pass fails: the download stays ok, only the media attempt failed", async () => { + const r = await runWith({ meta: META, transcript: TRANSCRIPT, managed: FORCED, mediaFail: true }); + try { + assert.equal(r.argvs.length, 3); + assert.equal(r.record.status, "ok"); + assert.equal(r.record.failureClass, undefined); + assert.deepEqual( + r.record.attempts.map((a) => [a.kind, a.ytdlpExitCode]), + [ + ["metadata-prefetch", 0], + ["primary", 0], + ["no-subs-fallback", 1], + ], + ); + assert.match(r.record.attempts[2].error ?? "", /HTTP Error 403: Forbidden/); + assert.match(sourceFetchFailure(r.record) ?? "", /HTTP Error 403: Forbidden/); + assert.match( + r.log, + /forceMedia: the source download failed \(yt-dlp exit 1\); the transcript on disk is untouched and the download stays ok\. Left for a retry to resume: source-media\.f137\.mp4\.part \(16 B\)\.\n/, + ); + // The transcript is untouched and still counts: the video stays in the + // archive as the subtitle pass left it. + assert.equal(await readFile(path.join(r.videoDir, TRANSCRIPT.name), "utf8"), TRANSCRIPT.body); + assert.match(r.archive, new RegExp(`youtube ${ID}`)); + // Nothing persisted; the partial is left for a retry to resume. + assert.ok(!r.files.includes("saved-video.json")); + assert.ok(r.files.includes("source-media.f137.mp4.part"), r.files.join(",")); + // The sidecar on disk says the same as the returned record. + const onDisk = JSON.parse( + await readFile(path.join(r.videoDir, "download-outcome.json"), "utf8"), + ) as DownloadOutcomeRecord; + assert.equal(onDisk.status, "ok"); + } finally { + await r.cleanup(); + } +}); + +test("sourceFetchFailure: a failed download, a failed forced pass, and the clean cases", () => { + const base = { videoId: ID, startedAt: "t0", finishedAt: "t1" }; + const attempt = (kind: DownloadOutcomeRecord["attempts"][number]["kind"], code: number | null, error?: string) => ({ + n: 1, + kind, + handling: "youtube" as const, + usedCookies: false, + ytdlpExitCode: code, + ...(error ? { error } : {}), + }); + assert.equal( + sourceFetchFailure({ ...base, status: "failed", attempts: [attempt("primary", 1, "ERROR: gone")] }), + "ERROR: gone", + ); + assert.equal( + sourceFetchFailure({ ...base, status: "failed-corrupt-source", attempts: [] }), + "the download ended failed-corrupt-source", + ); + assert.equal( + sourceFetchFailure({ + ...base, + status: "ok", + attempts: [attempt("primary", 0), attempt("no-subs-fallback", 1)], + }), + "yt-dlp exited 1", + ); + assert.equal( + sourceFetchFailure({ + ...base, + status: "ok", + attempts: [attempt("primary", 0), attempt("no-subs-fallback", 0)], + }), + null, + ); + assert.equal( + sourceFetchFailure({ ...base, status: "ok-with-cookies", attempts: [attempt("auth-retry", 0)] }), + null, + ); + // The fallback fetched and persisted the source, then inline whisper failed + // and ended the download `failed`: the SOURCE is there (review low 1). + assert.equal( + sourceFetchFailure({ + ...base, + status: "failed", + attempts: [attempt("primary", 0), attempt("no-subs-fallback", 0)], + }), + null, + ); +}); diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +- **The homepage can be built and deployed from `/sites`.** Under a new **Homepage** section, after Hub, there is **Build homepage** (tick **Deploy after build** to ship it in the same job, only if the build succeeds) and **Deploy homepage**, which ships the build already in `homepage/out`. A **Preview branch** box beside them sends either deploy to a Cloudflare Pages preview of the `archilyzer` project instead of production, and shows the preview's address as you type; a name Cloudflare would refuse or rewrite, or `main`, greys the deploy buttons out and says why. A line under the buttons says what a deploy would ship: when `homepage/out` was built (or that it holds no build yet), and where it goes, with the live URL. Deploy homepage with nothing built is refused before any job starts. The homepage reads the search index as it stands, so run **Build index** first when its numbers should move. The jobs run the same code as `archilyzer build homepage` / `deploy homepage`, and show on `/jobs` as `build-homepage`, `deploy-homepage` and `build-deploy-homepage`. The Hub section no longer describes the homepage. +- **`pnpm ops build-homepage` and `pnpm ops deploy-homepage`.** The same two jobs over HTTP: `build-homepage` takes `{"deploy": true}` to deploy after a successful build, and both take `{"preview": "<branch>"}` for a preview (`build-homepage` only with `deploy`). `deploy-homepage` answers with the preview's address, and refuses a bad preview name or a missing build before any job starts. +- **"Persist source video" or a whole-recording fetch that cannot get the source no longer marks the video's download failed.** When YouTube's subtitles came down but the source video did not, the video page said "Download failed" over a transcript that is fine. The download now keeps the subtitle pass's result and records only the failed media attempt, with yt-dlp's reason. The run itself now ends failed with that reason; it used to end done with no file, so `fetch_clip` could only say the job "finished but named no file". A partial source file is left for a retry to resume, and the run's log names it. +- **A bucket's retry keeps its log when it empties the bucket.** On a channel's Download stage, "Download with cookies", the partial-download resume and the missing-transcript retry could lose their run log part-way: the video they fetched left the bucket, the page refreshed, and the card disappeared with the log in it. The card now stays, with its log and its button disabled, until the page is reloaded. The Transcribe stage's "Fetch audio" button does the same. (A Diagnostics card still disappears, log and all, when its retry empties it.) +- **A release cut whose commit fails still refreshes the pages.** When the changelog's new heading was written but the commit after it failed, the Cut release form and `pnpm ops cut-release` answered as if nothing had happened and no page showed the new heading until a reload. Both now refresh the changelog pages, and `pnpm ops cut-release` says the file was written. Every refused cut's answer says whether anything was written (`untouched`), and a cut of both changelogs that stopped half-way names the one already cut as well as the failure. - **`archilyzer` checks the machine, runs one operation offline, starts the MCP server, and is one command from the repo root.** `pnpm archilyzer <command>` is the short form (`pnpm archilyzer --help` lists them all). `pnpm archilyzer doctor` is a read-only report: Node, the checkout, the corpus and whether each channel's media is reachable, `settings.json`, every tool the paths name plus each enabled worker's engine and model, umtool's report-pipeline tools, and this checkout's ports; it exits 1 only for something the machine is set up to do and cannot. `pnpm archilyzer run <operation> <channel> [ids…]` runs diarization, either attribution pass or digest over one channel as the editor's job does (a job record and log under `.jobs/`, the same summary line, the same refusal for an unmounted drive); sync, the metadata scan, downloads and transcription are refused with the reason, because they run on the editor's paced download queue and worker pool. `pnpm archilyzer mcp` starts the MCP server, so it can be registered as `-- pnpm -C "$PWD" archilyzer mcp`. Every other script in `common/bin/` is a subcommand too (`duplicates`, `posts fetch`, `digest plan`, `verify transcripts`, …), and export's `detect:duplicates` script is now `archilyzer duplicates`. Every environment variable is listed, by audience, in the new `ENVIRONMENT.md`, and `DEPLOY_CLOUDFLARE.md` and `DEPLOY_DOCKER.md` are now one `PUBLISH.md`. Settings no longer calls the Docker build mode a follow-up. ## [0.9.4] - 2026-09-28 diff --git a/editor/app/api/ops/build-homepage/route.ts b/editor/app/api/ops/build-homepage/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { + buildAndDeployHomepageAction, + buildHomepageAction, +} from "../../../sites/lib/homepageDeployActions"; +import { jobResponse, ops, optBool, optPreviewBranch, OpsInputError } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { deploy?: boolean, preview? } -> { ok: true, jobId } +// +// Builds the HOMEPAGE (the `homepage` package, Archilyzer's own site) into +// homepage/out. `deploy: true` makes it the one-job build-then-deploy (`/sites` +// → Homepage, "Deploy after build") to the Pages project `archilyzer`, which +// deploys only when the build exits 0; `preview` needs `deploy`. +export async function POST(request: Request) { + return ops(request, ["deploy", "preview"], async (body) => { + const deploy = optBool(body, "deploy") === true; + const preview = optPreviewBranch(body); + if (preview !== undefined && !deploy) { + throw new OpsInputError('"preview" needs "deploy": true — a build alone deploys nothing'); + } + return jobResponse( + deploy + ? await buildAndDeployHomepageAction(preview ? { previewBranch: preview } : undefined) + : await buildHomepageAction(), + ); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +} diff --git a/editor/app/api/ops/cut-release/route.ts b/editor/app/api/ops/cut-release/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { cutReleases, dateProblem, + describeCutFailure, RELEASE_TARGETS, versionSpecProblem, } from "yt-dlp-transcript-common/controller/cutRelease"; @@ -30,9 +31,12 @@ export const dynamic = "force-dynamic"; // commit each — or neither: every check runs before either file is written. // // 200 { ok: true, version, results } when every changelog was cut. A refusal -// is a 400 { ok: false, error, version, results, notAttempted, untouched? }. -// `untouched: true` means `all` was refused before writing anything; without -// it, `results` says what WAS done before a write or a commit failed. +// is a 400 { ok: false, error, version, results, notAttempted, untouched }. +// `untouched` is always there (release 11 slice O3): true means no changelog +// was written — every refusal, `all`'s preflight included; false means a cut +// stopped half-way (a write or a commit failed after something was written), +// and then `error` names what was already cut as well as what failed, and a +// result with `written: true` is a changelog cut whose commit failed. export async function POST(request: Request) { return ops(request, ["workspace", "version", "commit", "date"], async (body) => { const workspace = oneOf(body, "workspace", RELEASE_TARGETS); @@ -45,7 +49,9 @@ export async function POST(request: Request) { const commit = optBool(body, "commit") ?? false; const outcome = await cutReleases({ workspace, version, commit, date }); - if (outcome.results.some((r) => r.ok)) revalidateAfterReleaseCut(); + // Whenever a changelog changed on disk — a cut whose commit then failed + // included (review L3: it used to revalidate nothing). + if (outcome.untouched !== true) revalidateAfterReleaseCut(); if (outcome.ok) { return NextResponse.json({ ok: true, @@ -53,17 +59,11 @@ export async function POST(request: Request) { results: outcome.results, }); } - const failure = outcome.results.find((r) => !r.ok); - const reason = failure && !failure.ok ? failure.error : "cut failed"; - return opsFail( - workspace === "all" && failure ? `${failure.workspace}: ${reason}` : reason, - 400, - { - version: outcome.version, - results: outcome.results, - notAttempted: outcome.notAttempted, - ...(outcome.untouched ? { untouched: true } : {}), - }, - ); + return opsFail(describeCutFailure(outcome, workspace), 400, { + version: outcome.version, + results: outcome.results, + notAttempted: outcome.notAttempted, + untouched: outcome.untouched === true, + }); }); } diff --git a/editor/app/api/ops/deploy-homepage/route.ts b/editor/app/api/ops/deploy-homepage/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { HOMEPAGE_PAGES_PROJECT } from "yt-dlp-transcript-common/publish/build"; +import { deployHomepageAction } from "../../../sites/lib/homepageDeployActions"; +import { jobResponse, ops, optPreviewBranch } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { preview? } -> { ok: true, jobId, previewUrl? } +// +// DEPLOY ONLY — the homepage already built in homepage/out, to its constant +// Pages project `archilyzer`: production (branch `main`), or with `preview` a +// branch alias, knowable before the job runs. Refused before any job when the +// preview name is bad or homepage/out holds no build. +export async function POST(request: Request) { + return ops(request, ["preview"], async (body) => { + const preview = optPreviewBranch(body); + const res = jobResponse( + await deployHomepageAction(preview ? { previewBranch: preview } : undefined), + ); + if (!preview || res.status !== 200) return res; + const payload = (await res.json()) as Record<string, unknown>; + return NextResponse.json({ + ...payload, + previewUrl: previewAliasUrl(HOMEPAGE_PAGES_PROJECT, preview), + }); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +} diff --git a/editor/app/channels/[slug]/components/RetryBucketControl.tsx b/editor/app/channels/[slug]/components/RetryBucketControl.tsx @@ -27,6 +27,9 @@ type Props = { replaceAutoSubs?: boolean; // Overrides the default "Retry (n)" button text. buttonLabel?: string; + // Told when a run STARTS here, so a parent card that hides itself on an + // empty bucket can stay mounted for this run's log (see below). + onRun?: () => void; }; export function RetryBucketControl({ @@ -39,14 +42,24 @@ export function RetryBucketControl({ forceCookies, replaceAutoSubs, buttonLabel, + onRun, }: Props) { const [queue, setQueue] = useState(defaultQueueKey); const [handlingOverride, setHandlingOverride] = useState( replaceAutoSubs ? "transcribe" : "", ); const [abortOnError, setAbortOnError] = useState(false); + // A RETRY EMPTIES ITS OWN BUCKET, and the log must survive that (release 11 + // slice O3; plans/FACTS.md, "A run log lives in the panel's React state"). + // Every video the run fetches refreshes the snapshot, and the page's refresh + // then hands this control an empty `ids` while the run is still streaming — + // returning null there unmounted the StreamActionLog and took the log with + // it (cookies-mode.spec's intermittent "Retry needs cookies output" not + // found). Once a run has started here the control stays, with its button + // disabled while the bucket is empty; a reload drops it as before. + const [ranHere, setRanHere] = useState(false); - if (ids.length === 0) return null; + if (ids.length === 0 && !ranHere) return null; return ( <div @@ -54,8 +67,10 @@ export function RetryBucketControl({ aria-label={`retry ${actionLabel} bucket`} > <StreamActionLog - trigger={() => - retryBucketAction( + trigger={() => { + setRanHere(true); + onRun?.(); + return retryBucketAction( slug, ids, queue, @@ -64,8 +79,9 @@ export function RetryBucketControl({ bucketKey, forceCookies, replaceAutoSubs, - ) - } + ); + }} + disabled={ids.length === 0} cancelAction={cancelJobAction} buttonLabel={ buttonLabel diff --git a/editor/app/channels/[slug]/components/stages/DownloadStage.tsx b/editor/app/channels/[slug]/components/stages/DownloadStage.tsx @@ -352,7 +352,10 @@ function NoTranscriptList({ defaultQueueKey: string; existingQueues: string[]; }) { - if (ids.length === 0) return null; + // Stays mounted once its retry has run: the run empties this bucket, and + // the log must outlive that (RetryBucketControl says why). + const [ranHere, setRanHere] = useState(false); + if (ids.length === 0 && !ranHere) return null; return ( <div className="flex flex-col gap-2 rounded border border-border p-3"> <div> @@ -378,6 +381,7 @@ function NoTranscriptList({ defaultQueueKey={defaultQueueKey} existingQueues={existingQueues} bucketKey="noTranscript" + onRun={() => setRanHere(true)} /> </div> ); @@ -394,7 +398,10 @@ function PartialDownloadsList({ defaultQueueKey: string; existingQueues: string[]; }) { - if (ids.length === 0) return null; + // Stays mounted once its retry has run: the run empties this bucket, and + // the log must outlive that (RetryBucketControl says why). + const [ranHere, setRanHere] = useState(false); + if (ids.length === 0 && !ranHere) return null; return ( <div className="flex flex-col gap-2 rounded border border-border p-3"> <div> @@ -423,6 +430,7 @@ function PartialDownloadsList({ defaultQueueKey={defaultQueueKey} existingQueues={existingQueues} bucketKey="partialDownloads" + onRun={() => setRanHere(true)} /> </div> ); @@ -439,7 +447,10 @@ function NeedsCookiesList({ defaultQueueKey: string; existingQueues: string[]; }) { - if (ids.length === 0) return null; + // Stays mounted once its retry has run: the run empties this bucket, and + // the log must outlive that (RetryBucketControl says why). + const [ranHere, setRanHere] = useState(false); + if (ids.length === 0 && !ranHere) return null; return ( <div className="flex flex-col gap-2 rounded border border-border p-3"> <div> @@ -470,6 +481,7 @@ function NeedsCookiesList({ defaultQueueKey={defaultQueueKey} existingQueues={existingQueues} bucketKey="needsCookies" + onRun={() => setRanHere(true)} forceCookies /> </div> diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -70,7 +70,10 @@ import { } from "yt-dlp-transcript-common/jobs/downloadBackoff"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { resolveCookiePolicy } from "yt-dlp-transcript-common/lib/cookiePolicy"; -import { downloadOneManaged } from "yt-dlp-transcript-common/ytdlp/downloadOneManaged"; +import { + downloadOneManaged, + sourceFetchFailure, +} from "yt-dlp-transcript-common/ytdlp/downloadOneManaged"; import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp"; import { runManagedFunction, @@ -291,7 +294,7 @@ async function archiveSourceVideo( onLog(`${requesterLine(persistOrigin)}\n`); } onLog(`Re-downloading ${videoId} to archive its source video…\n`); - await downloadOneManaged({ + const record = await downloadOneManaged({ channelSlug: slug, channelConfig: r.config, paths, @@ -314,6 +317,16 @@ async function archiveSourceVideo( `/channels/${slug}/videos/${videoId}`, `/channels/${slug}`, ]); + // THE JOB IS THE SOURCE, SO ITS STATUS IS THE SOURCE'S (release 11 + // slice O3). A forced media pass that fails over a transcript leaves + // the download `ok` — the transcript is fine — so without this the job + // ended `done` with no file, and the whole-recording fetch's caller was + // told only that it "finished but named no file". Failing the job puts + // yt-dlp's line in the log tail the poll returns. + const failure = sourceFetchFailure(record); + if (failure) { + throw new Error(`The source video was not downloaded: ${failure}`); + } } finally { task.end(); } diff --git a/editor/app/sites/components/HomepageBuildButtons.tsx b/editor/app/sites/components/HomepageBuildButtons.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { + MAX_PREVIEW_BRANCH, + previewAliasUrl, + previewBranchProblem, +} from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { PROJECT_URL } from "yt-dlp-transcript-common/lib/project"; +import { + buildAndDeployHomepageAction, + buildHomepageAction, + deployHomepageAction, +} from "../lib/homepageDeployActions"; +import { useHydrated } from "../../operations/components/useOperationsStatus"; +import { JobLane } from "./JobLane"; + +type Lane = { + kind: "build" | "build-deploy" | "deploy"; + key: number; + // The preview branch AS LAUNCHED. Editing the field afterwards must not + // change what a running lane says it is deploying to. + preview: string | undefined; +}; + +const TITLE: Record<Lane["kind"], string> = { + build: "Build homepage", + "build-deploy": "Build & deploy homepage", + deploy: "Deploy homepage", +}; + +type Props = { + // The homepage's Pages project — the constant HOMEPAGE_PAGES_PROJECT, passed + // down because it lives beside the deploy code (server-only), not copied. + project: string; + // When homepage/out/index.html was built, formatted by the server; null + // when there is no build there. + builtAt: string | null; +}; + +// The homepage's build and deploy — the `homepage` package, built into +// homepage/out and deployed to its own Pages project. HubBuildButtons' shape +// exactly: one lane at a time (each launch replaces the previous lane; its job +// keeps running and stays on /jobs), and "Deploy after build" starts +// unchecked, because a homepage deploy replaces the software's public site. +// +// Plus a preview branch, which the hub's buttons do not have: empty is +// production, a name is a Cloudflare Pages preview of the same project. The +// name is judged by previewBranchProblem, the function the action and the ops +// route refuse with, so the buttons grey out on exactly the names the server +// would reject. +export function HomepageBuildButtons({ project, builtAt }: Props) { + const [deploy, setDeploy] = useState(false); + const [preview, setPreview] = useState(""); + const [lane, setLane] = useState<Lane | null>(null); + const [run, setRun] = useState(0); + const hydrated = useHydrated(); + + const branch = preview.trim(); + const problem = branch ? previewBranchProblem(branch) : null; + // Knowable before any deploy: project + branch and nothing else. + const alias = branch && problem === null ? previewAliasUrl(project, branch) : null; + + function launch(kind: Lane["kind"]) { + const next = run + 1; + setRun(next); + setLane({ kind, key: next, preview: branch || undefined }); + } + + function trigger(l: Lane) { + const opts = l.preview ? { previewBranch: l.preview } : undefined; + return l.kind === "build" + ? buildHomepageAction() + : l.kind === "build-deploy" + ? buildAndDeployHomepageAction(opts) + : deployHomepageAction(opts); + } + + return ( + // data-hydrated: a TESTING AFFORDANCE, as on the operation lanes. A click + // that lands before React hydrates fires nothing at all, so a spec waits + // for this before it clicks Build homepage. + <div + role="group" + aria-label="Homepage build" + className="flex flex-col gap-3" + data-hydrated={hydrated ? "true" : undefined} + > + <div className="flex flex-wrap items-center gap-3"> + <button + type="button" + onClick={() => launch(deploy ? "build-deploy" : "build")} + // A build alone deploys nothing, so only a build that will deploy + // is held back by a bad preview name. + disabled={deploy && problem !== null} + className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + Build homepage + </button> + <label className="flex items-center gap-2 text-sm text-muted-foreground"> + <input + type="checkbox" + checked={deploy} + onChange={(e) => setDeploy(e.target.checked)} + /> + Deploy after build + </label> + <button + type="button" + onClick={() => launch("deploy")} + disabled={problem !== null} + className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50" + > + Deploy homepage + </button> + <label className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground">Preview branch</span> + <input + type="text" + aria-label="preview branch" + placeholder="none: production" + value={preview} + // Twice the limit, so an over-long name is refused with the + // sentence that says WHY rather than silently truncated. + maxLength={MAX_PREVIEW_BRANCH * 2} + onChange={(e) => setPreview(e.target.value)} + className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm" + /> + </label> + </div> + {problem !== null && ( + <p + role="status" + // Not "preview branch problem": a name that CONTAINS the input's + // would make every by-label lookup for the input ambiguous. + aria-label="preview problem" + className="text-sm text-destructive" + > + {problem} + </p> + )} + <p className="text-xs text-muted-foreground" data-testid="homepage-ships"> + {builtAt ? ( + <> + Deploy homepage ships <code>homepage/out</code>, built {builtAt}, + </> + ) : ( + <> + <code>homepage/out</code> holds no build yet, so Deploy homepage has + nothing to ship. A deploy goes + </> + )}{" "} + to <code>{project}</code>{" "} + {!branch ? ( + <> + (production): <UrlLink url={PROJECT_URL} />. + </> + ) : alias ? ( + <> + (preview <code>{branch}</code>): <UrlLink url={alias} />; the live + site is left alone. + </> + ) : ( + <> + (preview <code>{branch}</code>). + </> + )} + </p> + <p className="text-xs text-muted-foreground"> + The homepage reads the search index as it stands: run Build index + (under Pool jobs, below) first when its numbers should move. + </p> + {lane && ( + <JobLane + key={lane.key} + title={TITLE[lane.kind]} + subtitle={ + lane.kind === "build" + ? "homepage/out" + : `homepage/out · ${project}${lane.preview ? ` (preview ${lane.preview})` : " (production)"}` + } + trigger={() => trigger(lane)} + /> + )} + </div> + ); +} + +function UrlLink({ url }: { url: string }) { + return ( + <a className="font-mono underline" href={url} target="_blank" rel="noreferrer"> + {url} + </a> + ); +} diff --git a/editor/app/sites/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx @@ -44,12 +44,26 @@ export function JobLane({ title, subtitle, trigger }: Props) { const [cancelling, setCancelling] = useState(false); const preRef = useRef<HTMLPreElement | null>(null); const startedRef = useRef(false); + // Whether the lane is mounted, kept by its OWN effect. It used to be a flag + // local to the launch effect below, set by that effect's cleanup — and + // Strict Mode (on under `next dev`) runs mount → cleanup → mount: the + // cleanup set the flag, the second run returned early on startedRef, and + // every update of the one launch there is was dropped. The job ran; the lane + // sat on "Starting…" forever. Declared first, so on the remount it is true + // again before anything reads it. Production mounts once and never saw it. + const aliveRef = useRef(false); + useEffect(() => { + aliveRef.current = true; + return () => { + aliveRef.current = false; + }; + }, []); useEffect(() => { // Strict-mode mounts effects twice in dev; guard so the job launches once. if (startedRef.current) return; startedRef.current = true; - let cancelled = false; + const unmounted = () => !aliveRef.current; let stopped = false; let pollTimer: ReturnType<typeof setTimeout> | null = null; @@ -64,7 +78,7 @@ export function JobLane({ title, subtitle, trigger }: Props) { status: string; queuePosition?: number; }; - if (!cancelled) { + if (!unmounted()) { if (data.status === "queued") { setStatus("queued"); setQueuePos( @@ -81,7 +95,7 @@ export function JobLane({ title, subtitle, trigger }: Props) { } catch { /* transient; keep polling */ } - if (!cancelled && !stopped) pollTimer = setTimeout(() => poll(id), 1000); + if (!unmounted() && !stopped) pollTimer = setTimeout(() => poll(id), 1000); } (async () => { @@ -89,13 +103,13 @@ export function JobLane({ title, subtitle, trigger }: Props) { try { result = await trigger(); } catch (e) { - if (!cancelled) { + if (!unmounted()) { setStatus("error"); setError((e as Error).message); } return; } - if (cancelled) return; + if (unmounted()) return; if (!result.ok) { setStatus("error"); setError(result.error); @@ -110,7 +124,7 @@ export function JobLane({ title, subtitle, trigger }: Props) { while (true) { const { value, done } = await reader.read(); if (done) break; - if (value && !cancelled) setLog((prev) => prev + value); + if (value && !unmounted()) setLog((prev) => prev + value); } } catch { /* stream tear-down */ @@ -118,7 +132,7 @@ export function JobLane({ title, subtitle, trigger }: Props) { const term = await result.done; stopped = true; if (pollTimer) clearTimeout(pollTimer); - if (!cancelled) { + if (!unmounted()) { setQueuePos(null); setStatus( term.status === "done" @@ -131,7 +145,6 @@ export function JobLane({ title, subtitle, trigger }: Props) { })(); return () => { - cancelled = true; if (pollTimer) clearTimeout(pollTimer); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/editor/app/sites/lib/cutReleaseAction.ts b/editor/app/sites/lib/cutReleaseAction.ts @@ -38,7 +38,13 @@ export async function cutReleaseAction( version, commit: shouldCommit, }); - if (!result.ok) return { ok: false, error: result.error }; + if (!result.ok) { + // The heading was written and only the commit after it failed (release 11 + // slice O3, release 10 review L3): the pages that render this changelog + // are stale all the same. The error already says the file changed. + if (result.written) revalidateAfterReleaseCut(); + return { ok: false, error: result.error }; + } revalidateAfterReleaseCut(); return { ok: true, version: result.version, committed: result.committed }; } diff --git a/editor/app/sites/lib/homepageDeployActions.ts b/editor/app/sites/lib/homepageDeployActions.ts @@ -0,0 +1,104 @@ +"use server"; + +import { builtHomepageProblem } from "yt-dlp-transcript-common/lib/builtExport"; +import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; +import { + runManagedFunction, + type StreamActionResult, +} from "yt-dlp-transcript-common/jobs/streamCommand"; +import { + buildHomepage, + deployHomepage, + homepageOutDir, +} from "yt-dlp-transcript-common/publish/build"; + +// The homepage's build and deploy, as jobs — the `homepage` package, Archilyzer's +// own site, deployed to the constant Pages project `archilyzer` +// (HOMEPAGE_PAGES_PROJECT). The same three shapes as the hub's (hubActions.ts) +// and the same bodies `archilyzer build homepage` / `deploy homepage` run. +// +// The build writes homepage/out, not export/out, so it cannot race a site +// build for export/ — but `next build` is heavy, and the build queue is what +// serialises heavy builds. The deploys take the deploy queue. +const BUILD_QUEUE = "build"; +const DEPLOY_QUEUE = "deploy"; + +// Refusals a deploy can give BEFORE any job starts: a bad preview name and — +// for a deploy-only — no build in homepage/out. No project check: the project +// is a constant. deployHomepage checks both again when its job runs, because a +// queued deploy can start after the build it was going to ship is gone. +function deployRefusal( + paths: Paths, + previewBranch: string | undefined, + checkBuilt: boolean, +): string | null { + if (previewBranch !== undefined) { + const problem = previewBranchProblem(previewBranch); + if (problem) return problem; + } + if (checkBuilt) return builtHomepageProblem(homepageOutDir(paths)); + return null; +} + +export async function buildHomepageAction(): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "build-homepage", + queueKey: BUILD_QUEUE, + paths, + fn: async (onLog, signal) => { + const code = await buildHomepage({ paths, onLog, signal }); + if (signal.aborted) return; + if (code !== 0) throw new Error(`Homepage build failed (exit ${code})`); + }, + }); +} + +// Deploy the homepage already built in homepage/out: production (branch +// `main`), or with `opts.previewBranch` a Cloudflare Pages PREVIEW of the +// `archilyzer` project. +export async function deployHomepageAction(opts?: { + previewBranch?: string; +}): Promise<StreamActionResult> { + const paths = getPaths(); + const refusal = deployRefusal(paths, opts?.previewBranch, true); + if (refusal) return { ok: false, error: refusal }; + const branch = opts?.previewBranch?.trim(); + return runManagedFunction({ + kind: "deploy-homepage", + queueKey: DEPLOY_QUEUE, + paths, + fn: (onLog, signal) => + deployHomepage({ paths, onLog, signal, previewBranch: branch }), + }); +} + +// Build the homepage and, only if that succeeds, deploy it — one job, one log, +// one Cancel, on the deploy queue (as build-deploy-hub is for the hub). A failed +// build never reaches the deploy: homepage/out would still hold the PREVIOUS +// build, and deploying that after a failure would ship old news as new. +export async function buildAndDeployHomepageAction(opts?: { + previewBranch?: string; +}): Promise<StreamActionResult> { + const paths = getPaths(); + // Before the build: learning the preview name is wrong after a build wastes it. + const refusal = deployRefusal(paths, opts?.previewBranch, false); + if (refusal) return { ok: false, error: refusal }; + const branch = opts?.previewBranch?.trim(); + return runManagedFunction({ + kind: "build-deploy-homepage", + queueKey: DEPLOY_QUEUE, + paths, + fn: async (onLog, signal) => { + onLog("=== Build homepage ===\n"); + const code = await buildHomepage({ paths, onLog, signal }); + if (signal.aborted) return; + if (code !== 0) { + throw new Error(`Homepage build failed (exit ${code}) — not deploying.`); + } + onLog("\n=== Deploy homepage ===\n"); + await deployHomepage({ paths, onLog, signal, previewBranch: branch }); + }, + }); +} diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx @@ -8,10 +8,15 @@ import { suggestNextVersion, } from "yt-dlp-transcript-common/lib/changelog"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { builtHomepageAt } from "yt-dlp-transcript-common/lib/builtExport"; import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { listSites } from "yt-dlp-transcript-common/lib/site"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { + HOMEPAGE_PAGES_PROJECT, + homepageOutDir, +} from "yt-dlp-transcript-common/publish/build"; import { liveJobRows } from "../jobs/active/buildActiveJobs"; import { RunningJobsList } from "../jobs/components/RunningJobsList"; import { BuildAllSitesButton } from "./components/BuildAllSitesButton"; @@ -19,6 +24,7 @@ import { BuildButtons } from "./components/BuildButtons"; import { BuildModeToggle } from "./components/BuildModeToggle"; import { BuildSitesPanel } from "./components/BuildSitesPanel"; import { CutReleaseForm } from "./components/CutReleaseForm"; +import { HomepageBuildButtons } from "./components/HomepageBuildButtons"; import { HomepageConfigForm } from "./components/HomepageConfigForm"; import { HubBuildButtons } from "./components/HubBuildButtons"; import { DeleteSiteButton, MigrateButton } from "./components/SiteListActions"; @@ -30,8 +36,9 @@ export const metadata: Metadata = { title: "Sites" }; // buttons enqueue (BuildButtons.tsx → sites/lib/buildAction.ts), and only // those. build-export and build-deploy are a site's Publish tab's, build-all // and build-deploy-all are the batch panel's above, build-hub / deploy-hub / -// build-deploy-hub the Hub section's — each has its own console and is not -// repeated here. +// build-deploy-hub the Hub section's, build-homepage / deploy-homepage / +// build-deploy-homepage the Homepage section's — each has its own console and +// is not repeated here. const BUILD_KINDS = new Set([ "build-index", "build-stats", @@ -76,6 +83,7 @@ export default async function SitesPage() { // (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds). const activeJobs = await liveJobRows((j) => BUILD_KINDS.has(j.kind)); const hubConfig = getHomepageConfig(paths); + const homepageBuiltAt = builtHomepageAt(homepageOutDir(paths)); return ( <div className="flex flex-col gap-8"> @@ -192,17 +200,39 @@ export default async function SitesPage() { site that has a public URL, reading each archive where it is published. This config names and brands it, and its Public URL and Cloudflare Pages project are the hub&rsquo;s own (for example{" "} - <code>archilyzer-hub</code>). The <code>homepage</code> package — - Archilyzer&rsquo;s own site, with the docs and the source download - — shares these social links and links to the hub, but deploys to - its own project, <code>archilyzer</code>, with{" "} - <code>archilyzer deploy homepage</code>. + <code>archilyzer-hub</code>). </p> </div> <HomepageConfigForm config={hubConfig} /> <HubBuildButtons project={hubConfig.cloudflareProject ?? null} /> </section> + {/* The software's own site: the `homepage` package, never the hub. It + has no config of its own here — it shares the hub form's social + links — so the section is only its build and deploy. */} + <section className="flex flex-col gap-3 border-t border-border pt-6"> + <div> + <h2 className="text-lg font-semibold">Homepage</h2> + <p className="mt-1 text-sm text-muted-foreground"> + The <code>homepage</code> package is Archilyzer&rsquo;s own site, + with the docs and the source download. It shares the Hub + form&rsquo;s social links and links to the hub, but it builds into{" "} + <code>homepage/out</code> and deploys to its own Pages project,{" "} + <code>{HOMEPAGE_PAGES_PROJECT}</code>. The buttons run the same code + as <code>archilyzer build homepage</code> and{" "} + <code>archilyzer deploy homepage</code>, as jobs. + </p> + </div> + <HomepageBuildButtons + project={HOMEPAGE_PAGES_PROJECT} + builtAt={ + homepageBuiltAt === null + ? null + : new Date(homepageBuiltAt).toLocaleString() + } + /> + </section> + {/* The shared pool: corpus-wide, no site involved. */} <section className="flex flex-col gap-3 border-t border-border pt-6"> <div> diff --git a/editor/e2e/cookies-mode.spec.ts b/editor/e2e/cookies-mode.spec.ts @@ -301,6 +301,23 @@ test("defer: excluded from batches, surfaced in Needs cookies, downloads via the // The bucket empties once the snapshot regenerates. await waitForNeedsCookiesBucket(0); + // …and the page refreshes onto the EMPTY bucket with the card still up and + // its run's log in it (release 11 slice O3). The card used to return null on + // an empty bucket, so whichever refresh landed first — the per-video + // snapshot's, mid-run — unmounted the log: the intermittent "Retry needs + // cookies output: element(s) not found" above. Waiting for the empty list + // makes that race certain rather than likely: it renders only after the + // refresh, inside the card that must have survived it. + await expect(page.getByLabel("needs cookies empty")).toBeVisible({ + timeout: 20_000, + }); + await expect(retryLog).toContainText( + "Managed download complete: 1 succeeded, 0 failed", + ); + await expect( + bucket.getByRole("button", { name: /^Download with cookies \(0\)$/ }), + ).toBeDisabled(); + // A fresh page has nothing to show for an empty bucket. await page.reload(); await expect(page.getByLabel("retry needs cookies bucket")).toHaveCount(0); }); diff --git a/editor/e2e/fetch-window.spec.ts b/editor/e2e/fetch-window.spec.ts @@ -9,7 +9,7 @@ // Same token as /api/worker/* (the test server runs with // WORKER_TOKEN=test-worker-token; see package.json dev:test). -import { readdir, readFile, stat } from "node:fs/promises"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; import { test, expect, type APIRequestContext } from "@playwright/test"; import { generateReport, @@ -455,3 +455,57 @@ test("a full-source fetch on a youtube-handling video that already has a transcr to: `Synthetic ${HAS_TRANSCRIPT}`, }); }); + +// …AND WHEN THAT DOWNLOAD FAILS, THE JOB SAYS SO (release 11 slice O3). The +// forced media pass failing over a transcript is not a failed download — the +// subtitle pass succeeded, the transcript is fine, so the outcome stays `ok` — +// but the job exists for the source, so the job fails, with yt-dlp's line in +// the log tail the poll returns. Before, it ended `done` with no file, and the +// MCP could only say the job "finished but named no file". +test("a full-source fetch whose media download fails ends failed with yt-dlp's line; the download stays ok", async ({ + request, +}) => { + test.setTimeout(90_000); + const HAS_TRANSCRIPT = "fake00000001"; + await writeFile(resolvePath(rel(".fake-ytdlp-media-fail")), ""); + const post = await request.post(`${baseUrl}/api/media/fetch-window`, { + headers: AUTH, + data: { + channelSlug: SLUG, + videoId: HAS_TRANSCRIPT, + full: true, + requestedBy: "mcp", + }, + }); + expect(post.status(), await post.text()).toBe(202); + const { jobId } = (await post.json()) as { jobId: string }; + + const finished = await pollJob(request, jobId); + expect(finished.status, JSON.stringify(finished)).toBe("failed"); + expect(finished.file).toBeUndefined(); + expect(String(finished.error)).toContain( + "The source video was not downloaded: ERROR: [download] Got error: HTTP Error 403: Forbidden", + ); + expect(String(finished.error)).toContain( + "forceMedia: the source download failed (yt-dlp exit 1); the transcript on disk is untouched and the download stays ok.", + ); + + const outcome = await readJson<{ + status: string; + attempts: Array<{ kind: string; ytdlpExitCode: number | null }>; + }>(rel(`data/${HAS_TRANSCRIPT}/download-outcome.json`)); + expect(outcome.status).toBe("ok"); + expect(outcome.attempts.map((a) => [a.kind, a.ytdlpExitCode])).toEqual([ + ["metadata-prefetch", 0], + ["primary", 0], + ["no-subs-fallback", 1], + ]); + const files = await readdir(resolvePath(rel(`data/${HAS_TRANSCRIPT}`))); + expect(files).toContain("transcript.en.vtt"); + expect(files).not.toContain("saved-video.json"); + // The partial is left for a retry to resume, and the log named it. + expect(files).toContain("source-media.f137.mp4.part"); + expect(String(finished.error)).toContain( + "Left for a retry to resume: source-media.f137.mp4.part (", + ); +}); diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs @@ -939,6 +939,19 @@ async function main() { await writeMetadata(videoDir, id, urlSentinels(url)); } process.stdout.write(`[download] Fetching ${id}\n`); + // `.fake-ytdlp-media-fail` in the channel root (the fake's cwd): the media + // download dies part-way — the partial yt-dlp leaves behind, then a 403 — + // for the forced-media failure specs (release 11 slice O3). + if (existsSync(".fake-ytdlp-media-fail")) { + await writeFile( + path.join(videoDir, "source-media.f137.mp4.part"), + `fake-ytdlp partial source container for ${id}\n`, + ); + process.stderr.write( + `ERROR: [download] Got error: HTTP Error 403: Forbidden\n`, + ); + process.exit(1); + } await writeFile( path.join(videoDir, "source-media.mp4"), `fake-ytdlp synthesised source container for ${id}\n`, diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -864,6 +864,24 @@ async function listJobIds(): Promise<string[]> { .sort(); } +// Hold the `build` AND `deploy` queues with fabricated running jobs +// (/api/test/stuck-job; never released by the caller), so any build or deploy +// job that a refusal under test failed to stop can only QUEUE, never run. BOTH +// queues, because a regression can turn a refused call into a job on either +// one: a dropped "preview needs deploy" guard makes a build, a dropped preview +// check makes a deploy. The fabricated holders write no .meta.json, so they are +// invisible to listJobIds; take `before` after this. The next resetData +// cancels jobs newest first, so a queued job is removed before a holder's slot +// is freed and nothing is ever promoted. +async function holdBuildAndDeployQueues(request: APIRequestContext) { + 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); + } +} + test("build-site and build-deploy each take siteId or siteIds, and refuse both or neither", async ({ request, }) => { @@ -1092,12 +1110,15 @@ test("build-site with a bare siteId starts one build-export job", async ({ // The hub's deploy path (release 7). Every refusal here is answered BEFORE a // job exists, which is what lets a runbook's `pnpm ops deploy-hub --wait` fail -// fast instead of queueing a deploy that can only fail. +// fast instead of queueing a deploy that can only fail. Both queues are held +// (release 11, O4 review): a regression of a refusal below would otherwise +// start a real hub build into export/out, or a real deploy. test("deploy-hub refuses no project, the homepage's project, and a bundle that is not the hub", async ({ request, }) => { await resetData("title-filter-channel"); await settings(); + await holdBuildAndDeployQueues(request); const before = await listJobIds(); // The fixture has no homepage.json at all: no project. @@ -1148,3 +1169,68 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i expect(await listJobIds()).toEqual(before); }); + +// The homepage's deploy path (release 11, slice O4). Every refusal is answered +// BEFORE a job exists, like deploy-hub's. +// +// NOTHING HERE CAN REACH WRANGLER, and three separate things make sure of it: +// 1. `homepage/out` is the CHECKOUT's (homepageOutDir = <repo>/homepage/out, +// not fixture-controlled — the primary checkout holds the live homepage's +// build), so the missing-build call is made only when this checkout has no +// `homepage/out/index.html`, as deploy-hub's is (211d4666); +// 2. that call asks for a PREVIEW, never production; +// 3. the whole test runs with BOTH the `build` and the `deploy` queue held +// by fabricated jobs (holdBuildAndDeployQueues). A build or deploy job +// that a refusal failed to stop — `build-homepage {preview}` is a build +// if the "preview needs deploy" guard regresses — would only QUEUE; the +// listJobIds assertion then fails, and the next resetData cancels jobs +// newest first, so the queued job is removed before a holder's slot is +// freed. +test("deploy-homepage refuses a bad preview name and a missing build; build-homepage refuses a preview without a deploy", async ({ + request, +}) => { + await resetData("empty"); + await settings(); + await holdBuildAndDeployQueues(request); + const before = await listJobIds(); + + // A bad preview name is judged before homepage/out is even looked at, so + // these hold in any checkout. + const production = await ops(request, "deploy-homepage", { preview: "main" }); + expect(production.status).toBe(400); + expect(production.body.error).toBe( + '"main" is the production branch; a preview needs another name.', + ); + const shape = await ops(request, "deploy-homepage", { preview: "Not_Valid" }); + expect(shape.status).toBe(400); + expect(shape.body.error).toMatch(/not a valid preview branch name/); + // And on the build-then-deploy, before anything is built. + const buildDeploy = await ops(request, "build-homepage", { + deploy: true, + preview: "main", + }); + expect(buildDeploy.status).toBe(400); + expect(buildDeploy.body.error).toContain("is the production branch"); + + // build-homepage: a preview without a deploy is a mistake, not a build. + const previewOnly = await ops(request, "build-homepage", { preview: "home-check" }); + expect(previewOnly.status).toBe(400); + expect(previewOnly.body.error).toContain('"preview" needs "deploy": true'); + + // No build in homepage/out. + if (await pathExists("../homepage/out/index.html")) { + test.info().annotations.push({ + type: "subcase-not-exercisable", + description: + "homepage/out holds a build in this checkout, so the unbuilt refusal cannot fire (and the call would deploy it)", + }); + } else { + const unbuilt = await ops(request, "deploy-homepage", { preview: "home-check" }); + expect(unbuilt.status).toBe(400); + expect(unbuilt.body.error).toBe( + "homepage/out holds no build — build the homepage first", + ); + } + + expect(await listJobIds()).toEqual(before); +}); diff --git a/editor/e2e/ops-cut-release.spec.ts b/editor/e2e/ops-cut-release.spec.ts @@ -116,6 +116,12 @@ test("cut-release cuts the editor changelog and answers with the heading it wrot error: "Could not find a `## [Unreleased]` heading to cut from.", }, ]); + // Every 400 says whether anything was written (release 11 slice O3): a + // refusal wrote nothing. A cut stopped half-way — a write or a commit that + // failed after a file changed — answers `untouched: false` with the earlier + // cut named in `error`; the controller's tests drive those (a git failure is + // not something this server's fixture changelogs can produce). + expect(again.body.untouched).toBe(true); }); test("all cuts both changelogs with ONE version, resolved against the higher latest", async ({ diff --git a/editor/e2e/persist-youtube-handling.spec.ts b/editor/e2e/persist-youtube-handling.spec.ts @@ -173,3 +173,48 @@ test("a download whose metadata changed upstream appends to metadata.history.jso await expect(entries).toContainText("view_count"); await expect(entries).toContainText("100 → 150"); }); + +// A FAILED MEDIA PASS IS NOT A FAILED DOWNLOAD (release 11 slice O3). With the +// fake's media download dying part-way, the run fails — the operator asked for +// the source and did not get it — but the download outcome stays the subtitle +// pass's `ok`, so the video page no longer says "Download failed" over a +// transcript that is fine. +test("Persist source video whose media download fails: the run fails with yt-dlp's line, the video is not marked Download failed", async ({ + page, +}) => { + test.setTimeout(120_000); + await resetData("youtube-with-playlist"); + await writeFile(resolvePath(rel(".fake-ytdlp-media-fail")), ""); + + await page.goto(`/channels/${SLUG}/videos/${HAS_TRANSCRIPT}`); + await page.getByLabel("Source video stage summary").click(); + await page.getByRole("button", { name: "Persist source video", exact: true }).click(); + const log = page.getByLabel(`Persist source video for ${HAS_TRANSCRIPT} output`); + await expect(log).toContainText( + "forceMedia: the source download failed (yt-dlp exit 1); the transcript on disk is untouched and the download stays ok.", + { timeout: 30_000 }, + ); + await expect(log).toContainText( + "[error] The source video was not downloaded: ERROR: [download] Got error: HTTP Error 403: Forbidden", + { timeout: 30_000 }, + ); + + const outcome = await outcomeOf(HAS_TRANSCRIPT); + expect(outcome?.status).toBe("ok"); + expect(outcome?.attempts.map((a) => [a.kind, a.ytdlpExitCode])).toEqual([ + ["metadata-prefetch", 0], + ["primary", 0], + ["no-subs-fallback", 1], + ]); + // The transcript is there (the subtitle pass re-fetched it, as on any + // re-download); the failed media pass wrote none. + const files = await readdir(resolvePath(rel(`data/${HAS_TRANSCRIPT}`))); + expect(files).toContain("transcript.en.vtt"); + expect(files).not.toContain("saved-video.json"); + + // The page, fresh: no failed-download badge, and nothing persisted. + await page.reload(); + await expect(page.getByLabel("download outcome")).toHaveCount(0); + await page.getByLabel("Source video stage summary").click(); + await expect(page.getByLabel(`unpersist source video ${HAS_TRANSCRIPT}`)).toHaveCount(0); +}); diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts @@ -0,0 +1,208 @@ +// /sites → Homepage: the `homepage` package's build and deploy (release 11, +// slice O4), the twin of the Hub section's buttons. +// +// NOTHING HERE DEPLOYS, AND NOTHING HERE BUILDS. Wrangler has no fake in +// e2e/fixtures/bin, and `homepage/out` is the CHECKOUT's own directory — in the +// primary checkout it holds the live homepage's build — so a deploy clicked +// 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 `deploy` queue with fabricated jobs (/api/test/stuck-job, never +// released here), so the job it 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. + +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"; + +const group = (page: Page) => + page.getByRole("group", { name: "Homepage build" }); + +// EXACT: a live lane's Cancel button is named "Cancel Build homepage", which +// a substring match for "Build homepage" would also find. +const buildButton = (page: Page) => + group(page).getByRole("button", { name: "Build homepage", exact: true }); +const deployButton = (page: Page) => + group(page).getByRole("button", { name: "Deploy homepage", exact: true }); +// Scoped: /sites has three other "Deploy after build" checkboxes (the batch, +// the specific-sites panel, the hub). +const deployAfter = (page: Page) => group(page).getByLabel("Deploy after build"); +const previewInput = (page: Page) => + group(page).getByRole("textbox", { name: "preview branch", exact: true }); + +// A click or a keystroke that lands before React hydrates is lost without a +// trace; the group stamps data-hydrated once its mount effect has run. +async function openSites(page: Page) { + await page.goto("/sites"); + await expect(group(page)).toHaveAttribute("data-hydrated", "true", { + timeout: 15_000, + }); +} + +type Meta = { + id: string; + kind: string; + queueKey?: string; + status: string; + startedAt?: number; +}; + +async function metasOfKind(kind: string): Promise<Meta[]> { + const files = ( + await readdir(resolvePath("test-transcripts/.jobs")).catch(() => []) + ).filter((f) => f.endsWith(".meta.json")); + const metas: Meta[] = []; + for (const f of files) { + const meta = await readJson<Meta>(`test-transcripts/.jobs/${f}`).catch( + () => null, + ); + if (meta?.kind === kind) metas.push(meta); + } + return metas; +} + +test.beforeEach(async () => { + await resetData("empty"); +}); + +test("the Homepage section follows Hub, with the build and deploy controls and what a deploy ships", async ({ + page, +}) => { + await page.goto("/sites"); + + const hub = page.getByRole("heading", { name: "Hub", exact: true }); + const home = page.getByRole("heading", { name: "Homepage", exact: true }); + await expect(home).toBeVisible(); + expect((await hub.boundingBox())!.y).toBeLessThan((await home.boundingBox())!.y); + + await expect(buildButton(page)).toBeEnabled(); + await expect(deployButton(page)).toBeEnabled(); + // Unticked, as the hub's: a homepage deploy replaces a public site, so it is + // never the default of a build. + await expect(deployAfter(page)).not.toBeChecked(); + // Empty is production. + await expect(previewInput(page)).toHaveValue(""); + + // One line: what homepage/out holds, the project, production, the live URL. + const ships = group(page).getByTestId("homepage-ships"); + if (await pathExists("../homepage/out/index.html")) { + await expect(ships).toContainText(/Deploy homepage ships homepage\/out, built /); + } else { + await expect(ships).toContainText( + "homepage/out holds no build yet, so Deploy homepage has nothing to ship.", + ); + } + await expect(ships).toContainText("to archilyzer (production)"); + await expect( + ships.getByRole("link", { name: "https://archilyzer.pages.dev", exact: true }), + ).toHaveAttribute("href", "https://archilyzer.pages.dev"); + await expect(group(page)).toContainText( + "The homepage reads the search index as it stands: run Build index", + ); + + // The homepage's prose moved out of the Hub section and into its own. + const hubSection = page.locator("section").filter({ has: hub }); + const homeSection = page.locator("section").filter({ has: home }); + await expect(hubSection).not.toContainText("archilyzer deploy homepage"); + await expect(homeSection).toContainText("archilyzer deploy homepage"); + await expect(homeSection).toContainText("homepage/out"); +}); + +test("a bad preview name greys out every deploy and says why; a good one shows its alias", async ({ + page, +}) => { + await openSites(page); + const ships = group(page).getByTestId("homepage-ships"); + + await previewInput(page).fill("main"); + await expect(deployButton(page)).toBeDisabled(); + await expect( + group(page).getByRole("status", { name: "preview problem" }), + ).toHaveText('"main" is the production branch; a preview needs another name.'); + // A build alone deploys nothing, so it stays available… + await expect(buildButton(page)).toBeEnabled(); + // …until it would deploy. + await deployAfter(page).check(); + await expect(buildButton(page)).toBeDisabled(); + + await previewInput(page).fill("Main"); + await expect( + group(page).getByRole("status", { name: "preview problem" }), + ).toContainText("not a valid preview branch name"); + await expect(buildButton(page)).toBeDisabled(); + + // A good name re-arms both and names the alias before anything runs. + await previewInput(page).fill("home-check"); + await expect(buildButton(page)).toBeEnabled(); + await expect(deployButton(page)).toBeEnabled(); + await expect( + group(page).getByRole("status", { name: "preview problem" }), + ).toHaveCount(0); + await expect(ships).toContainText("to archilyzer (preview home-check)"); + await expect( + ships.getByRole("link", { + name: "https://home-check.archilyzer.pages.dev", + exact: true, + }), + ).toBeVisible(); + + // Cleared, it is production again. + await previewInput(page).fill(""); + await expect(ships).toContainText("to archilyzer (production)"); + + // Nothing above was clicked: no homepage job of any kind exists. + for (const kind of ["build-homepage", "deploy-homepage", "build-deploy-homepage"]) { + expect(await metasOfKind(kind), kind).toEqual([]); + } +}); + +test("Build homepage starts a build-homepage job on the build queue (held there, then cancelled from its lane)", async ({ + page, + request, +}) => { + // See the header: with both queues held, whatever the click starts can only + // queue. + 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 expect(deployAfter(page)).not.toBeChecked(); + await buildButton(page).click(); + + // The lane: queued behind the holder — not refused, not running. + await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 }); + await expect(group(page).getByRole("alert")).toHaveCount(0); + + // One build-homepage job, on the build queue, queued — and nothing else. + await expect.poll(async () => (await metasOfKind("build-homepage")).length).toBe(1); + const [job] = await metasOfKind("build-homepage"); + expect(job.queueKey).toBe("build"); + expect(job.status).toBe("queued"); + expect(await metasOfKind("build-deploy-homepage")).toEqual([]); + expect(await metasOfKind("deploy-homepage")).toEqual([]); + + await group(page).getByRole("button", { name: "Cancel Build homepage" }).click(); + await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible(); + + // It never started: the registry says cancelled, and start() — which opens + // the job's log file before it runs anything — never ran. + const res = await request.get( + `${baseUrl}/api/jobs/${encodeURIComponent(job.id)}/log?from=0`, + ); + const log = (await res.json()) as { status: string; content: string }; + expect(log.status).toBe("cancelled"); + expect(log.content).toBe(""); + expect(await pathExists(`test-transcripts/.jobs/${job.id}.log`)).toBe(false); +}); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **A video whose recheck failed shows as possibly missing rather than available.** When a video drops out of its channel's listing it is marked "Missing?" until a recheck says why. A recheck that could not reach the video — a blocked request or a network error — used to clear the mark as if the video had been found. It now leaves "Missing?" in place until a recheck actually reaches the video. Needs a rebuild and deploy of every export site. + ## [0.9.4] - 2026-09-28 - **On the Dark ground the mark's tile has a thin outline.** The header mark's ink tile is the colour of the Dark page, so only its lines showed. On Dark the tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines. The small Archilyzer mark by the footer credit and the hub's mark get the same ring in their own slate's unlit colour. Light and Sepia are unchanged, and so are the icons. Needs a rebuild and deploy of every export site and the hub. diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -3696,6 +3696,21 @@ box that outlives the warning stops answering `getByLabel("incomplete transcript (`cards/PerFileTranscodeRow.tsx:39` since 3b) is NOT an instance: its `return null` depends on the file's extension against a static format list, which a transcode cannot change, and the rows are keyed by file name. +**On the channel page, the bucket cards** (release 11 slice O3, `86449b88`): `RetryBucketControl` +and the Download stage's three bucket cards (`NoTranscriptList`, `PartialDownloadsList`, +`NeedsCookiesList`) returned null on an empty bucket, and a retry EMPTIES its own bucket. What hands +the card its emptied bucket is not only the end-of-run refresh: `recordTaskDone` → +`requestSnapshotOnFinish` (`common/jobs/streamCommand.ts`) regenerates the snapshot after EACH video +a batch finishes, the pulse moves, and `AutoRefresh` refreshes the page MID-RUN. Now the shape +above: `ranHere` set in the trigger (and `onRun` to tell the card), null only while +`ids.length === 0 && !ranHere`, `disabled={ids.length === 0}` (`RetryBucketControl.tsx:60-84`, +`DownloadStage.tsx:357,404,454`). `cookies-mode.spec.ts:241` waits for the refreshed EMPTY card +(`needs cookies empty`), which makes the old race certain rather than likely. The Transcribe +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. + 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 output template naming `source-media` (only `plan.extractionMode === "app"` emits that), which @@ -6780,8 +6795,46 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below `transcript.json` is untouched. It rewrites `metadata.info.json` (every managed download's prefetch does); since slice N each rewrite that changes the bytes appends to `metadata.history.json` (`common/lib/metadataHistory.ts`). The window path never writes metadata. + **A forced pass that FAILS over a transcript** (release 11 slice O3) keeps the download's status + (the subtitle pass's `ok`) and records only the failed n: 3 attempt; `archiveSourceVideo` reads + it through `sourceFetchFailure` and throws, so the job ends `failed` with yt-dlp's line in the + poll's `error` tail — it used to end `done` with no file ("finished but named no file"). Neither path dedupes a running job: a repeated request while one runs queues a second fetch, which is why every text says "resume with job". - The editor must already HAVE the channel (404 `Channel "<slug>" not found` otherwise); a public-only setup gets the "no editor configured" error naming both env vars, and the README's `yt-dlp --download-sections` is the no-editor fallback only. + +### O3 — runner lows (release 11, 2026-09-28) + +- **An `error` probe never confirms a maybe-missing video** (`resolveMaybeMissingState`, + `common/lib/availability-server.ts:156-170`, `:164`). `stateFromAvailability` folds `error` (a + 403, a network failure, the bot check, a blocked run's one rate-limited probe) into `available` + because it is no evidence the video is gone; it is no evidence it is there either, so a probe + after the scan that came back `error` now leaves it `maybe_missing`. `needs_auth` (an age gate) + after the scan still confirms. The ONE caller is `buildIndex.ts:1180` (the published `state` on a + site's summaries pages, and the `videoState` sub-DB behind the status chart); the clean gate reads + `resolveEffectiveAvailability`, which the fix does not touch. `maybeMissingBuild.test.ts` pins it + through the real `buildIndex`. The shared transcript pages carry no `state`; the summaries (and + subs) pages do. +- **The five real videos it moved (read-only count, 2026-09-28)** were all Rumble probes that got + `HTTP Error 410: Gone` on 2026-09-25 and were stored as `error`, just before `6d5cdbc3` taught + `parseUnavailableFromStderr` that 410 is `deleted`: `rekietalaw-rumble/v7e07us` and + `the-quartering-rumble/v4vriou v4x5o1l v4yqask v501kfc`. A "Full-check unexpected" on those two + channels would settle them `deleted`. +- **`sourceFetchFailure(record)`** (`common/ytdlp/downloadOneManaged.ts`) is how a caller that + asked for the SOURCE tells whether it got it. When the last attempt is the `no-subs-fallback` + media pass, ITS exit code alone answers (a fallback that persisted the container and whose inline + whisper then failed ends `failed`, and the source is there — review low 1); otherwise the status + (`failed`, `failed-corrupt-source`). Read by `archiveSourceVideo` (throws) and `persistKept` + (counts it `failed`, not `persisted`). A failed + `keepTranscript` pass (`:1503`) leaves the status alone and logs `forceMedia: the source download + failed (yt-dlp exit N); the transcript on disk is untouched and the download stays <status>.`, + naming any `source-media.*.part` it leaves (left on purpose: a retry resumes it). +- **`cutReleases` always sets `untouched`** (`common/controller/cutRelease.ts`, `nothingWritten` + `:311`): true = no changelog was written (every refusal); false once any was, including a result + with **`written: true`** — a cut whose file was written and whose commit then failed (`:253`). + The type keeps it optional only for hand-built outcomes (`common/bin/_cli.test.ts`). + `describeCutFailure(outcome, target)` (`:415`) is the ops route's 400 `error`: the failure, + `<workspace>: `-prefixed for `all`, then `Before it, editor was already cut (## [x] - date, + committed <sha8>).` The route revalidates whenever `untouched !== true`. diff --git a/plans/homepage-deploy-from-ui.md b/plans/homepage-deploy-from-ui.md @@ -116,3 +116,32 @@ Numbers tool: none. The live :3001 editor must be restarted to show the section, which fits the next editor restart. After it: Build homepage with Deploy after build unticked, then Deploy homepage, and the job's log ends with the Pages URL. + +## As shipped (2026-09-28, release 11 slice O4) + +Built as planned; record: [`release-11.md`](release-11.md), "Slice O4, as shipped". What the +build corrected here: +- **The ops preview is a body key, not a flag.** `pnpm ops deploy-homepage [--preview <branch>]` + (decision 4) is `pnpm ops deploy-homepage --json '{"preview":"<branch>"}'`: the ops client has no + per-verb flags, and `deploy-hub` takes its preview the same way. +- **No job-kind registry entries.** The hub's three kinds have none in `common/jobs/jobKinds.ts`, + and neither does any publish kind (`build-export`, `deploy-export`, `build-deploy`, `build-all`, + `build-deploy-all`). A kind with no entry shows its raw name on `/jobs` and is not drainable, not + replayable and needs no media, which is right for all three. Mirrored: the kinds are declared + where the hub's are, in the action's `kind` and in `/sites`' `BUILD_KINDS` comment. +- **`homepageOutDir(paths)` is exported from `common/publish/build.ts`.** The editor needs + `homepage/out` for `builtHomepageProblem` and for "built <when>", and `homepageDir` was private. + After review, `deployHomepage` takes its directory from `homepageOutDir` too, with no behaviour + change. Otherwise `buildHomepage` and `deployHomepage` are unchanged, so the job's own refusal + keeps its sentence ("… — run archilyzer build homepage first"). The refusal before the job says + "… — build the homepage first". +- **`JobLane` needed a fix to be testable at all.** Under `next dev`, Strict Mode's + mount → cleanup → mount dropped every update of a lane's one launch, so it sat on "Starting…" + while its job ran. Production mounts once and never showed it; no spec had clicked a lane + button before. +- **How the e2e avoids a real `next build` and wrangler.** There is no fake wrangler, and the hub's + buttons have no spec. The Build homepage spec holds the `build` and `deploy` queues with + `/api/test/stuck-job`, so its job only queues, and it cancels the job from the lane before it + starts. The deploy-homepage refusals run with both queues held the same way (after review; each + test held only one queue at first). The missing-build refusal is asked only + when the checkout has no `homepage/out/index.html`, and asked as a preview. diff --git a/plans/release-11.md b/plans/release-11.md @@ -36,6 +36,363 @@ merges `main` once O1–O5 have landed, then does its `E2E_` rename. Then one in ## Record +### Slice O4, as shipped — deploy the homepage from /sites (2026-09-28) + +The operator's ask (2026-09-26): **"Is there a way to redeploy archilyzer.pages.dev from the UI?"** +There was not: `/sites` built and deployed the hub, and the homepage was a CLI +(`archilyzer build|deploy homepage`) with no job, no `/jobs` row and no `pnpm ops` verb. Plan: +[`homepage-deploy-from-ui.md`](homepage-deploy-from-ui.md), decisions 1–5, now with an "As shipped" +note. Branch `r11/homepage-deploy-ui` off `main` `2162db92`, worktree +`/home/user/Projects/r11-homepage-deploy-ui`, one Opus implementer. Scratch files `o4-*` in the +job's `tmp/overnight`. + +**What shipped.** +- **A Homepage section on `/sites`, after Hub.** The Hub paragraph lost its homepage sentence, and + the new section says it instead: the `homepage` package, `homepage/out`, the project `archilyzer`, + and the same code as `archilyzer build|deploy homepage`. Its group, **Homepage build**, is + `HubBuildButtons`' shape: **Build homepage**, **Deploy after build** (unticked), **Deploy + homepage**, one `JobLane` at a time (**Build homepage** / **Build & deploy homepage** / + **Deploy homepage**). +- **A preview branch box** (textbox "preview branch", empty = production). It is judged by + `previewBranchProblem`, the function the action and the route refuse with. A bad name greys out + Deploy homepage, and Build homepage when Deploy after build is ticked, and shows the sentence + (`role="status"`, "preview problem"). +- **One line saying what a deploy ships:** "Deploy homepage ships `homepage/out`, built <when>, to + `archilyzer` (production): https://archilyzer.pages.dev." (`PROJECT_URL`). With no build it says + "`homepage/out` holds no build yet …". With a preview it gives the alias and says "the live site + is left alone". Under it: the homepage reads the index as it stands, so run Build index first. +- **Three job kinds,** via `runManagedFunction` in `editor/app/sites/lib/homepageDeployActions.ts`: + - `build-homepage` on queue `build`; + - `deploy-homepage` and `build-deploy-homepage` on queue `deploy`; + - the bodies are `buildHomepage` / `deployHomepage`, unchanged. The build-deploy deploys only + on exit 0. +- **Refusals before any job:** a bad preview name, and for a deploy-only, + `builtHomepageProblem(homepageOutDir(paths))` ("homepage/out holds no build — build the homepage + first"). `deployHomepage` re-checks both inside the job with its own sentences, untouched. +- **Ops:** + - `/api/ops/build-homepage` `{deploy?, preview?}`: a preview without deploy is a 400. + - `/api/ops/deploy-homepage` `{preview?}` answers `previewUrl` = + `previewAliasUrl(HOMEPAGE_PAGES_PROJECT, preview)`. + - `pnpm ops build-homepage` / `deploy-homepage`, in the verb list, the header examples and the + usage. +- **common:** `builtHomepageProblem` and `builtHomepageAt` (index.html's mtime) beside + `builtHubProblem` in `builtExport.ts`. `homepageOutDir(paths)` exported from `publish/build.ts`, + so the editor does not re-derive the path. +- **Found and fixed: `JobLane` under Strict Mode.** The launch effect gated its updates on a flag + local to it, set by its cleanup. `next dev`'s mount → cleanup → mount set that flag, and the + second run returned early on `startedRef`, so the job ran while the lane sat on "Starting…" + forever. Liveness is now a ref kept by its own effect. Production mounts once and is unchanged. + No spec had ever clicked a lane button (batch, specific sites, hub), which is how it went unseen. + +**Corrections to the plan** (also in its "As shipped" note): +- The ops preview is `--json '{"preview":"<b>"}'`, not a `--preview` flag. +- No `jobKinds.ts` entries: the hub's kinds have none, and neither does any publish kind. +- `homepageOutDir` had to be exported. + +**How nothing in e2e builds or deploys.** There is no fake wrangler, `homepage/out` is the +checkout's own directory (the primary's holds the live homepage's build), and the hub's buttons have +no spec to copy. So: +- **No spec clicks Deploy homepage.** None ticks Deploy after build and then clicks Build. +- **Build homepage is clicked only with the `build` and `deploy` queues both held** by + `/api/test/stuck-job`, which is never released in the spec. The `build-homepage` job only + queues, and so would a `build-deploy-homepage` if the click regressed into one. The spec cancels + it from its lane. Its `start()` never runs: no log file, no child, nothing written under + `homepage/`. +- **If that spec fails before its Cancel,** the next `resetData` cancels live jobs newest first + (`registry.list()` sorts by `queuedAt` descending). The queued build is removed before the + holder's slot is freed, so nothing is ever promoted. +- **The deploy-homepage refusals run with both queues held the same way** (`holdBuildAndDeployQueues` + in `ops-api.spec`). A build or deploy that a refusal failed to stop would only queue, and the + spec's job-list check would fail. +- **The missing-build refusal is asked only when the checkout has no `homepage/out/index.html`,** as + `deploy-hub`'s is (`211d4666`), and it is asked as a preview. +- **Checked after every run:** the worktree has no `homepage/out`, and `homepage/public` holds only + its tracked `_headers`. Its gitignored data was not copied, because nothing here builds the + homepage. + +| sha | what | +|---|---| +| `b047b1be` | `common:` `builtHomepageProblem` + `builtHomepageAt` (`builtExport.ts`), `homepageOutDir` (`publish/build.ts`); +3 unit tests | +| `7ba41164` | `sites:` `homepageDeployActions.ts`, `HomepageBuildButtons.tsx`, the Homepage section; the Hub paragraph loses its homepage sentence | +| `54021099` | `ops:` `/api/ops/build-homepage`, `/api/ops/deploy-homepage`; `archilyzer-ops.mjs` verbs + usage (+1 test) | +| `ba4ff0bb` | `e2e:` `ops-api.spec` +1 (the refusals), `sites-homepage.spec` (3) | +| `d52d017d` | `sites:` `JobLane` survives Strict Mode | +| `d9218cb7` | `sites:` the group stamps `data-hydrated`; the specs wait for it | +| `40b6a0d7` | `changelog:` `[Unreleased]` above `[0.9.4]` in `editor/CHANGELOG.md` | +| `ff8b9382` | `plans:` this record; the plan's "As shipped" note | +| `72175134` | `e2e:` review fix — the homepage refusal test, the Build homepage test and the hub's refusal test hold both the `build` and the `deploy` queue | +| `275c0fa6` | `common:` review fix — `deployHomepage` ships `homepageOutDir(paths)` (no behaviour change) | +| _this_ | `plans:` the review fixes in this record and the plan's note | + +**Gates** (logs `o4-*.log`): +- **tsc** clean before every code commit (the last run on `d9218cb7`'s tree). +- **common 2,041/2,041** (2,038 + 3), editor unit **85/85**, `test:scripts` **175 + 1 skipped** + (174 + 1, +1), mcp **269/269**. +- **Editor build** ok (59 s); `/api/ops/build-homepage` and `/api/ops/deploy-homepage` are in the + route list. +- **e2e:** + - Run 1 on `ba4ff0bb` (`ops-api`, `sites-homepage`, `sites-crud`, `site-publish-preview`, + `deploy-page`, `build`, `settings`): **63 passed, 1 failed** (2.8 min). The failure was + `sites-homepage`'s Build homepage test: the lane stayed on "Starting…", which is the `JobLane` + bug above. + - `sites-homepage` ×3 with the old `JobLane`: **6 passed, 3 failed**. + - The same ×3 on `d9218cb7`: **9 passed** (27 s). + - The final list on `d9218cb7` (the same seven specs): **64 passed** (1.8 min). + - After the review fixes, on `275c0fa6`: `ops-api` + `sites-homepage` **26 passed** (40.5 s). + tsc was clean, common 2,041/2,041 and `build.test.ts` 10/10. The worktree still has no + `homepage/out`. + - No spec here greps `deploy-hub|build-hub|HubBuild` except `ops-api`, which is in the list. +- **Numbers tool:** none. + +**They bite:** +- **Unit, against `2162db92`'s source:** + - `builtExport.test.ts` and `build.test.ts` fail to load (no `builtHomepageAt` / + `homepageOutDir` export). + - A mutation, `builtHomepageProblem` checking the directory instead of `index.html`, fails 1 of 8. + - `archilyzer-ops.test.mjs` against the old script: 1 of 33 fails, the new test. +- **e2e, against `2162db92`'s `page.tsx`, `builtExport.ts` and `build.ts`,** with the new action, + component and routes moved out: **4 of 4 new tests fail.** `ops-api` hits a 404 page ("Unexpected + token '<'"), and `sites-homepage` finds no Homepage group. The tree was restored by a trap + afterwards. +- **The `JobLane` fix:** the Build homepage test fails 3 of 3 with the old lane and passes 3 of 3 + with the fix. + +**Review fixes** (review: SHIP AFTER FIXES, `o4-review.md`): +- **Should-fix 1, one queue held where a regression could reach the other.** The deploy-homepage + refusal test held only `deploy`. A regressed "preview needs deploy" guard would have run a real + `build-homepage` there: in the primary checkout, compose writes fixture numbers into + `homepage/public`, then `next build` runs. The Build homepage test held only `build`, so a click + turned into build-and-deploy would have reached `deploy`. Both tests now hold both queues. So does + the hub's `deploy-hub` / `build-hub` refusal test (the review's low 4). That test **predates this + slice** (release 7) and held nothing. In `ops-api.spec` the holds sit in one helper, + `holdBuildAndDeployQueues`, and `before` is taken after them. +- **Low 2:** `deployHomepage` now takes its directory from `homepageOutDir(paths)`. This is a pure + refactor, and what it ships is exactly the directory the editor judged "built". +- **Low 3** (the "built <when>" line does not refresh after an in-page build) is left as recorded + below. + +**Found and left** +- **A job cancelled while still queued keeps `"status":"queued"` in its `.meta.json`.** The + `onCancel` path never rewrites the sidecar. The registry says cancelled, and the boot pass settles + the sidecar. This is pre-existing and was not touched. +- **The "built <when>" line is read when `/sites` renders,** so a lane that finishes a build does + not refresh it. The Hub's lanes do not refresh the page either. +- **`/jobs` shows the three kinds by their raw names,** as it does the hub's. +- **Nothing has been built or deployed through the new jobs:** no build and no deploy tonight, by + rule. The proof is the rollout below. + +**Rollout (owed):** the section and the routes exist only on an editor built from this code, so it +waits on the :3001 restart. After it: +1. **Build homepage** with Deploy after build unticked, or `pnpm ops build-homepage --wait`. +2. **Deploy homepage**, or `pnpm ops deploy-homepage --wait`. The job's log ends with + `[deployed] https://<hash>.archilyzer.pages.dev`. + +### Slice O3, as shipped — runner lows (2026-09-28) + +Branch `r11/runner-lows` off `main` `2162db92`, worktree `/home/user/Projects/r11-runner-lows`, one +Opus implementer, beside O4 and O6. Four lows left by release 10: one from L2 ("New low"), one from +slice N's review (L2) and its knock-ons, the `cookies-mode.spec:241` intermittent N's full suite +found, and slice P's review lows L3 + L4. No settings, site or channel key; nothing on disk moves. +`CutReleaseResult.written` and an always-set `CutReleasesOutcome.untouched` are additive. + +**1 — a failed confirm probe no longer clears "Missing?"** (`common/lib/availability-server.ts`). +- `resolveMaybeMissingState` decides the published state of a video that fell out of its + channel's listing. A probe made after the scan that came back `public` clears the flag; so did + one that came back `error`, through `stateFromAvailability`'s `error` → `available` fold. An + `error` probe (a 403, a network failure, the bot check, a blocked run's one rate-limited probe) + never saw the video, so it is no evidence it is there. It now leaves the video `maybe_missing`, + like one never probed. `needs_auth` after the scan still confirms: an age gate is the video + answering. +- **Display only, and the clean gate is untouched — proof.** `grep -rn resolveMaybeMissingState` + over the whole repo (not `node_modules` or `.next`) finds one caller, `buildIndex.ts:1180` (the maybe-missing overlay → + `stateByIndexKey` → a site's summaries pages and the `videoState` sub-DB the status chart + reads), plus `videoState.test.ts`. The diff to `availability-server.ts` is the one line and its + comment; `resolveEffectiveAvailability`, which `verifyBeforeClean` and the MtimeRecord read, is + not in it. +- **The export check** is `common/controller/maybeMissingBuild.test.ts` (new), which runs the REAL + `buildIndex` over a temp corpus: one video per outcome of the overlay, and the `state` each gets + on the site's `summaries/page-0000.json` — the field the export's badge and Availability filter + read. `export/e2e/availability-state.spec.ts` was NOT extended: it fulfils the summaries with + mocked `state`s, so a new case there could not see the fix; it was re-run (9/9) as the contract + check that `state: "maybe_missing"` still draws "Missing?". The shared transcript pages carry no + `state` (found while writing the test). +- **The real count, read-only** (`o3-flip-count.ts` in the job scratch dir, `o3-flip-count.log`: reads + `maybe-missing.json`, `availability.json`, `download-outcome.json`, `metadata.info.json`'s + existence and `sites/*/site.json` through the build's own loaders; opens no LMDB, writes nothing): + 1,827 maybe-missing ids in 31 channels; **5 videos across 2 channels move from available to + maybe-missing at the next index build** — `rekietalaw-rumble/v7e07us` and + `the-quartering-rumble/v4vriou v4x5o1l v4yqask v501kfc`. Per site: **jeralyzer 4** ("Missing?" + 212 → 216), **rekietalyzer 1** (0 → 1), anilyzer, bonnellyzer, hasanalyzer and jasolyzer 0. All + five are Rumble probes that got `HTTP Error 410: Gone` on 2026-09-25 and were stored as `error`, + hours before `6d5cdbc3` taught the classifier that 410 is `deleted`; they are really deleted, and + "Missing?" is closer than "available". A "Full-check unexpected" on those two channels would + settle them as Deleted (the operator's call; nothing was run). + +**2 — a failed forced download keeps the subtitle pass's status** (`common/ytdlp/downloadOneManaged.ts`). +- The attempt-3 `else` set `status = "failed"` and `lastSucceeded = false` for a `keepTranscript` + pass too, so a "Persist source video", `fetch_clip` `full: true` or Persist kept now whose MEDIA + failed put "Download failed" on a video whose transcript is fine. A `keepTranscript` failure now + keeps the status (the subtitle pass's `ok` / `ok-with-cookies`) and `lastSucceeded`, so the + archive line the subtitle pass printed is appended as on the success path; the failed n: 3 + attempt is recorded with its error (no `failureClass`: the download did not fail). One log line: + `forceMedia: the source download failed (yt-dlp exit N); the transcript on disk is untouched and + the download stays ok. Left for a retry to resume: source-media.f137.mp4.part (<size>).` +- **Knock-on 1, fixed: the job reports it.** `sourceFetchFailure(record)` (exported) says whether a + download asked for the source got it: the status for an outright failure, the last attempt for a + failed forced pass. `archiveSourceVideo` (`videoActions.ts`, both "Persist source video" and + `fetchFullSourceAction`) throws `The source video was not downloaded: <yt-dlp's line>` after its + revalidate, so the job ends `failed` and the poll's `error` tail carries the line; the MCP renders + "Editor job … failed" with it instead of "finished but named no file". A download that failed + outright ends the job `failed` too (before: `done`). +- **Knock-on 2, left on purpose: the `.part`.** yt-dlp resumes a partial container on a retry, and + deleting one that is several GB makes a retry after a network blip start over — more requests + against the source, which the pacing rules want fewer of. Nothing else surfaces a + `source-media.*.part` (the Partial downloads bucket matches `audio.<ext>.part` only), so the log + line names it and its size instead. + +**3 — the `cookies-mode.spec:241` intermittent** (`DownloadStage.tsx`, `RetryBucketControl.tsx`). +- The mechanism is wider than the end-of-run refresh: `recordTaskDone` → `requestSnapshotOnFinish` + regenerates the snapshot after EACH video a batch finishes, the pulse moves and `AutoRefresh` + refreshes the page while the run still streams. The refreshed `NeedsCookiesList` got an empty + bucket and returned null, and so did the `RetryBucketControl` in it — two unmounts, either + enough to lose the log. +- The FACTS shape, in both: `ranHere` set inside the trigger (`RetryBucketControl` also calls a new + optional `onRun` so its card knows), null only while `ids.length === 0 && !ranHere`, and + `disabled={ids.length === 0}` so the surviving panel is not a second Run button. Applied to all + three bucket cards on the Download stage (`NoTranscriptList`, `PartialDownloadsList`, + `NeedsCookiesList`, same hazard) and to `RetryBucketControl` itself. Beyond the Download stage + it reaches one more control: the Transcribe stage's **Fetch audio** (its section stays mounted + while `autoSubsCount > 0`, and a fetch moves videos from "Needs audio" to "Ready to transcribe"). + It does NOT reach Diagnostics: both of its grids drop an emptied bucket's whole card + (`DiagnosticsStage.tsx:156` `populated`, `:694` `listed`), control and log with it (corrected in + review; see Review fixes). A reload drops an empty card as before (the reload check at the end of + the same test still passes); labels and test ids unchanged. +- **Made deterministic.** The spec now waits for the refreshed, EMPTY card (`needs cookies empty`, + which renders only inside a card that survived the refresh) and asserts the log's last line and a + disabled `Download with cookies (0)` in it. On the old code this fails every time, at that line + (below). + +**4 — cut-release review lows L3 + L4** (`common/controller/cutRelease.ts`, `api/ops/cut-release`). +- **L3:** `applyCut`'s commit failure returns `{ok: false, workspace, error, written: true}` — the + file on disk has its new heading. The route revalidates whenever anything was written. +- **L4:** `cutReleases` sets `untouched` on EVERY outcome (true = no changelog written, every refusal + included; false once one was, a `written` failure included); the type keeps it optional only so + the CLI's hand-built display tests compile, and absent reads as false. `describeCutFailure` + builds the route's 400 `error`: the failure (prefixed `<workspace>: ` for `all`), then `Before + it, editor was already cut (## [0.9.5] - 2026-09-28, committed 1a2b3c4d).` Every 400 from the + writer carries `untouched`. An `all` whose FIRST write fails wrote nothing and is `untouched: true`, so the CLI + prints "not cut — all cuts both or neither, and nothing was written" for the export — true. + The input refusals before the writer (a bad workspace, version, date or key) are the ops door's + 400s and carry no `untouched`, as before. +- The CLI (`common/bin/release.ts`) is unchanged: its per-line output already named the earlier cut + and says "failed — Cut release X, but the commit failed" for L3. +- **The `/sites` form** (`editor/app/sites/lib/cutReleaseAction.ts`) revalidates on `written: true` + too. It is under `editor/app/sites/**`, which slice O4 owned tonight, so it landed after O4's + merge (Review fixes). + +| sha | what | +|---|---| +| `877403db` | 1: `resolveMaybeMissingState` — `error` after the scan stays `maybe_missing`; `videoState.test.ts` +2, `maybeMissingBuild.test.ts` (new, 1) | +| `8c72265c` | 2: the `keepTranscript` failure keeps the status; `sourceFetchFailure`; `archiveSourceVideo` fails the job; `forceMedia.test.ts` +2; the fake's `.fake-ytdlp-media-fail` knob; `fetch-window.spec.ts` +1, `persist-youtube-handling.spec.ts` +1 | +| `86449b88` | 3: `ranHere` / `onRun` / disabled in `RetryBucketControl` and the three Download-stage bucket cards; `cookies-mode.spec.ts:241` made deterministic | +| `edec55ca` | 4: `written`, always-set `untouched`, `describeCutFailure`, the route; `cutRelease.test.ts` +4 (1 changed); `ops-cut-release.spec.ts` pins `untouched` | +| _this_ | `plans:` this record, FACTS (three amendments, one new section), the `[Unreleased]` bullets | + +**Gates**, from the worktree root; logs `o3-*.log` in `$T`. +- **tsc** (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) clean before each code + commit: `o3-tsc1` (item 1), `o3-tsc3` (items 2 + 3, item 4 stashed), `o3-tsc4` (item 4). +- **common 2,047/2,047** (release 10's 2,038 + 9: videoState +2, maybeMissingBuild +1, forceMedia + +2, cutRelease +4); **editor unit 85/85**; **`test:scripts` 174 + 1 skip of 175**; **mcp + 269/269** (`o3-units1.log`, on `edec55ca`). +- **Editor build** `pnpm --filter editor exec next build` ok, 55 s (`o3-build1.log`; the route table + lists `ƒ /api/ops/cut-release`). **Export build not run**: nothing under `export/` changed but + its CHANGELOG. +- **e2e** (queued, detached, `export/public` linked per path from the primary with `sw.js` a plain + copy, no dangling links; the lock was free each time): + + | run | specs | passed | failed | time | + |---|---|---|---|---| + | A | `cookies-mode maybe-missing persist-youtube-handling fetch-window saved-videos cut-release ops-cut-release retry-bucket partial-downloads-bucket auto-subs-replace bulk-actions no-subs-fallback storage-locations` (`o3-e2e-specs.txt`: the prompt's seven, the four that drive a retry-bucket control, and the two that drive the source-media branch) | **61** | **0** | 3.9 min | + | B | `cookies-mode.spec.ts --repeat-each=3` | **18** | **0** | 1.8 min | + | C | EXPORT `availability-state.spec.ts` | **9** | **0** | 30 s | + + `archives-off.spec.ts` draws no state badge, so it was not added. The primary's + `export/public/sw.js` was not written (md5 `355d0d21…`, mtime 00:51:32, before and after). +- **Numbers tool:** `o3-flip-count.ts` (item 1), above. + +**They bite.** +- Unit, item 1 (`o3-bite1.log`): with the `error` line reverted, `maybeMissingBuild.test.ts` fails + and `videoState.test.ts`'s "error, probed AFTER the scan" fails; 15 others pass. +- Unit, items 2 + 4 (`o3-bite2.log`): with the `keepTranscript` branch removed, forceMedia's new + behaviour test fails (the `sourceFetchFailure` table test is of a new function); with `written` + and the always-set `untouched` reverted, 5 of 20 cutRelease tests fail (the four new and the + changed half-way test). +- **e2e** (`o3-e2e-bite.log`): the six source files at their `2162db92` versions, the new specs and + fake kept; `fetch-window persist-youtube-handling cookies-mode ops-cut-release`: **16 passed, 4 + failed, 2.5 min**, and the four are exactly the new and changed tests — + `cookies-mode.spec.ts:241` at `getByLabel("needs cookies empty")` (the card is gone), + `fetch-window.spec.ts:465` (`done`, not `failed`), `ops-cut-release.spec.ts:73` + (`untouched` undefined), `persist-youtube-handling.spec.ts:182` (no failure line; the old run + marked the download failed). Restored with `git checkout HEAD --`. + +**Found and left.** +- ~~`persistKept` still counts a returned download as persisted~~: done in the review fixes. +- **L3/L4 have no HTTP e2e for the half-way cases.** The fixture server's changelogs are gitignored + copies, so a commit there fails only after the dirty-tree guard has run git in the WORKTREE, and a + failed write needs a read-only directory the server's path cannot be pointed at. The controller's + tests drive both in temp repos (a refusing pre-commit hook; a `0555` directory); the spec pins + `untouched: true` on a refusal. +- **A Diagnostics card after its run.** A Diagnostics retry that empties its bucket still loses + its card and its log: `DiagnosticsStage.tsx:156` / `:694` filter emptied buckets out before the + control renders. The follow-up is to lift a ran-bucket set into both sections and keep those + cards in `populated` / `listed`. +- **The five 410 videos** are published as available today and will read "Missing?" after the next + build; a Full-check unexpected would make them Deleted. + +**Review fixes** (review SHIP AFTER FIXES, `o3-review.md` in the job scratch dir: one should-fix, wording only; three +lows; one nit; the three questions ruled — keep `archiveSourceVideo`'s failure broad, staying +mounted is fine as a rule, keep `untouched` optional tonight). +- **Merge.** `export/.next/dev` removed first (run C's export dev server had left stale types there, + so `tsc` failed in `export/` — low 3, environment only), then `git merge main` at `baaa4b47` (O4 + merged). Conflicts only in `editor/CHANGELOG.md` (one `[Unreleased]`: O4's two bullets, then + O3's three) and this file's Record (O4's section, then O3's). +- **The deferred `/sites` form change.** `cutReleaseAction` revalidates when the result is + `written: true`, then returns the error as before. The reviewer read it as correct and complete; + no e2e can make the fixture server's commit fail after the write, so tsc and the controller's + tests cover it, and `cut-release.spec.ts` re-ran green. +- **should-fix — the Diagnostics claim was false.** The record and the changelog said the Diagnostics + buckets' retry controls keep their log; both Diagnostics grids drop an emptied bucket's card, so + they do not. Item 3 above, the editor bullet ("The Transcribe stage's "Fetch audio" button does the + same. (A Diagnostics card still disappears…)"), FACTS and found-and-left now say exactly what is + covered: the Download stage's three cards and the Transcribe stage's Fetch audio. +- **low 1 — `sourceFetchFailure`'s order.** A no-subs fallback that fetched and persisted the source + and whose inline whisper then failed ends the download `failed`; reading the status first called + that source "not downloaded". When the last attempt is the media pass, its exit code alone + answers now; otherwise the status. Latent here (`inlineTranscribeOnFallback` is off on the + primary). +- **low 2 — `persistKept`.** It counted every returned download as `persisted`; it now reads + `sourceFetchFailure` and counts a failed forced pass as `failed`, with the reason in its log. +- **nit.** The inert `key="retry"` on the Download stage's three retry controls is gone. + +| sha | what | +|---|---| +| `0c770285` | merge `main` (`baaa4b47`, O4): the changelog and Record conflicts | +| `f62d00f1` | the Cut release form revalidates on `written: true` | +| `e9947421` | low 1: `sourceFetchFailure` reads a media-pass last attempt first; `forceMedia.test.ts`'s table +1 row | +| `315c76e1` | low 2: `persistKept` counts a failed forced pass as failed; `persistKeptForceMedia.test.ts` +1 (setup made one helper with a process-wide settings file) | +| `5c9987d2` | nit: the inert keys dropped | +| _this_ | `plans:` these fixes, the corrected wording (record, changelog, FACTS) | + +**Gates on `5c9987d2`:** +- tsc clean after the merge (`o3-tsc5.log`) and on the fixes (`o3-tsc6.log`); every fix commit is a + disjoint set of files from that tree. +- common **2,051/2,051** (2,041 on `main` after O4, + O3's 10), editor unit **85/85**, + `test:scripts` **175 + 1 skip of 176**, mcp **269/269** (`o3-round2.log`). +- e2e `cut-release ops-cut-release persist-youtube-handling fetch-window saved-videos cookies-mode` + (`o3-e2e-specs2.txt`, `o3-e2e2-full.log`): **28 passed, 0 failed, 1.3 min**, no queue wait. +- **They bite** (`o3-bite3.log`): with the old order in `sourceFetchFailure`, the table test fails on + the new row; with `persistKept` counting every return, the new persistKept test fails; the other + 8 in the two files pass. ### Slice O6, as shipped — one-core Phase 4 slice 3 (2026-09-28) — checkpoint A Branch `r11/phase-4-s3` off `main` `2162db92` (no later `main` to merge before the first commit), diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -37,6 +37,8 @@ // pnpm ops build-hub --wait // pnpm ops build-hub --json '{"deploy":true}' --wait // pnpm ops deploy-hub --wait +// pnpm ops build-homepage --json '{"deploy":true}' --wait +// pnpm ops deploy-homepage --json '{"preview":"refresh"}' --wait // pnpm ops get channel the-quartering // pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}' // pnpm ops tag-videos --file ids.json @@ -106,6 +108,10 @@ const ACTIONS = [ // build uses) and its deploy to homepage.json's Pages project. "build-hub", "deploy-hub", + // The HOMEPAGE (the `homepage` package, Archilyzer's own site, into + // homepage/out) and its deploy to the constant Pages project `archilyzer`. + "build-homepage", + "deploy-homepage", "relocate", "relocate-back", "evict-clips", @@ -309,6 +315,12 @@ export function usage() { ' after, and deploy-hub ships the one already built. Both deploy to the', " Pages project set on /sites under Hub, and take \"preview\" too.", "", + 'build-homepage builds the homepage package into homepage/out;', + ' {"deploy": true} deploys it after (only if the build succeeded), and', + " deploy-homepage ships the one already built. Both deploy to the Pages", + " project archilyzer (https://archilyzer.pages.dev), production unless", + ' "preview" is given.', + "", 'cut-release turns a changelog\'s [Unreleased] into "## [<version>] - <date>":', ' {"workspace": "editor" | "export" | "all",', ' "version": "X.Y.Z" | "next" | "next-minor",', diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -361,3 +361,20 @@ test("build-hub and deploy-hub are POSTs to their own routes, named in the usage assert.match(usage(), /Actions:.*build-hub, deploy-hub/); assert.match(usage(), /build-hub builds the hub into export\/out/); }); + +// The homepage's pair (release 11 slice O4), the hub's twins: a POST each, the +// body passed through untouched — the routes judge it. +test("build-homepage and deploy-homepage are POSTs to their own routes, named in the usage", () => { + const build = parseArgs(["build-homepage", "--json", '{"deploy":true}', "--wait"]); + assert.equal(build.method, "POST"); + assert.equal(build.path, "/api/ops/build-homepage"); + assert.deepEqual(build.body, { deploy: true }); + assert.equal(build.wait, true); + const deploy = parseArgs(["deploy-homepage", "--json", '{"preview":"refresh"}']); + assert.equal(deploy.method, "POST"); + assert.equal(deploy.path, "/api/ops/deploy-homepage"); + assert.deepEqual(deploy.body, { preview: "refresh" }); + assert.match(usage(), /Actions:.*build-homepage, deploy-homepage/); + assert.match(usage(), /build-homepage builds the homepage package into homepage\/out/); + assert.match(usage(), /project archilyzer \(https:\/\/archilyzer\.pages\.dev\)/); +});