Archilyzer · Source

archilyzer

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

commit f7f2f1898713ac7139af4e6a63790529ae2f07f3
parent 45b6eada6508600daf7a3e37d26adb6e660d4fbd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 17:56:08 -0400

common: sync is a catalogued operation, with a scope and a trigger

Sync had no descriptor, so the board, the rail and /operations/<id> could not
reach it off the same table as everything else. It is in the catalog now, first
— upstream of every entry that depends on the videos it notices.

Two fields carry what makes it different. `scope` says what an operation has a
state FOR (a video, or a channel), and `trigger` says what makes it run (a
backlog, or a cadence). Neither is a workaround for `runner`: that is typed to
the AutoQueueKinds and the sync heartbeat is not one of them, so a console
chosen off `runner` could never be sync's. ExternalOperation is now a projection
of OperationDescriptor rather than a second copy of its field list, which is why
the two new fields arrive there once.

`sync` is a fifth OperationGroup and deliberately NOT in OPERATION_GROUP_ORDER:
that list is the per-video pipeline's stations, and sync draws no /channels
column. GROUP_STAGES.sync is empty for the same reason — its channel surface is
the hand-listed playlist bookend. pauseLaneFor answers null for it: the
scheduler's own `enabled` is its switch, not a lane gate.

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

Diffstat:
Mcommon/controller/videoOperations.ts | 4+++-
Mcommon/lib/operations.test.ts | 19+++++++++++++++++++
Mcommon/lib/operations.ts | 122++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcommon/lib/pauseGates.test.ts | 5++++-
Mcommon/lib/pauseGates.ts | 6++++--
Meditor/app/channels/[slug]/lib/stageStatus.ts | 6++++++
Meditor/app/channels/page.tsx | 5+++++
Meditor/app/components/pipelines/buildBands.ts | 6++++++
8 files changed, 137 insertions(+), 36 deletions(-)

diff --git a/common/controller/videoOperations.ts b/common/controller/videoOperations.ts @@ -17,7 +17,9 @@ // transcription already have a per-video surface — the pipeline stage cards // inside the editor's VideoPanel, which carry their own per-video actions. This // reader is over OPERATIONS, the backfill/derived-data registry, and that -// asymmetry is intentional rather than an omission. +// asymmetry is intentional rather than an omission. Sync is absent for a +// stronger reason still: it is `scope: "channel"`, so a per-video reader has +// nothing to ask it. // // ONE readVideoFiles PER PAGE. It is a readdir, and the registry's state() // takes the listing as its probe rather than re-reading it, so the whole page diff --git a/common/lib/operations.test.ts b/common/lib/operations.test.ts @@ -1222,6 +1222,7 @@ test("the backfill lane never dispatches digest", () => { test("the catalog covers every operation, dispatched here or not", () => { const ids = operationCatalog().map((o) => o.id); for (const id of [ + "sync", "download", "transcode", "transcription", @@ -1257,6 +1258,11 @@ test("`runner` names the auto-queue runner, and only for the two that have one", if (op.id === "download" || op.id === "transcription") continue; assert.equal(op.runner, undefined, `${op.id} declares a runner`); } + // Sync included, and it is the interesting one: the sync scheduler's + // heartbeat IS a runner in the IA doc's sense, but `runner` is typed to the + // AutoQueueKinds and the heartbeat is not one of them. Its console is chosen + // off `trigger` instead — see the scope/trigger test below. + assert.equal(runners.get("sync"), undefined); }); test("`appliesTo` is transcode's alone, and it reads the channel config", () => { @@ -1335,10 +1341,23 @@ test("the three speaker operations share one group; digest does not", () => { assert.equal(operationGroup("digest"), "digest"); assert.equal(operationGroup("download"), "media"); assert.equal(operationGroup("transcription"), "transcript"); + // Sync is its own group, not `media`: that group is download + transcode's + // /channels column group, and sync draws no column. + assert.equal(operationGroup("sync"), "sync"); // An id the catalog does not know is null, NOT filed under the first group. assert.equal(operationGroup("no-such-operation"), null); }); +test("`scope` and `trigger`: sync is the one channel-scoped, cadence-triggered operation", () => { + // The two fields exist so a per-video surface can EXCLUDE sync by a declared + // fact rather than by its id. Every other entry is per-video and backlog-fed. + for (const op of operationCatalog()) { + assert.equal(op.scope === "channel", op.id === "sync", `${op.id} scope ${op.scope}`); + assert.equal(op.trigger === "cadence", op.id === "sync", `${op.id} trigger ${op.trigger}`); + } + assert.equal(operationCatalog()[0].id, "sync"); // upstream first +}); + test("a set's label is derived, so a mixed lane cannot claim one member's name", () => { // The whole reason this is derived: the backfill lane holds three speaker // operations today and its station can honestly say "Speakers". Add a kind diff --git a/common/lib/operations.ts b/common/lib/operations.ts @@ -242,6 +242,16 @@ export type Lane = { // on it resolves. See EXTERNAL_OPERATIONS. export type OperationDispatch = "backfill" | "external"; +// WHAT AN OPERATION ACTS ON, and WHAT MAKES IT RUN. Every media-derived +// operation is per-video and backlog-driven: it has a state per video, and its +// work list is whichever videos lack it. Sync is the one exception the IA doc +// names (plans/editor-operations-ia.md, "Where the noun breaks"): a CHANNEL has +// a sync state — last synced, next due — and nothing runs it but a cadence. A +// surface that draws per-video things (a band, a video-page panel, a channel +// column, a worker tag) filters on `scope`, never on the id. +export type OperationScope = "video" | "channel"; +export type OperationTrigger = "backlog" | "cadence"; + export type OperationProbe = { videoDir: string; videoId: string; @@ -319,7 +329,7 @@ export type OperationRunOutcome = // Deriving the label from the group also means a lane holding a MIX cannot go // stale: it falls back to "Derived data" rather than naming two of its three // members. -export type OperationGroup = "media" | "transcript" | "digest" | "speakers"; +export type OperationGroup = "media" | "transcript" | "digest" | "speakers" | "sync"; // See Operation.settingsBlock. A closed union rather than `string` so the switch // that picks a settings form is exhaustive: a new block has to be named here and @@ -329,6 +339,10 @@ export type OperationSettingsBlock = "digest" | "diarization" | "attribution"; // Group order: upstream first. The /channels columns and the transit line both // lay their pipelines out in this order, so a reader moving between the two // pages sees the same left-to-right sequence. +// +// `sync` is deliberately NOT here: this list is the per-video pipeline's +// stations, and sync draws no channel column and no station of its own (its +// channel surface is the hand-listed `playlist` bookend — see GROUP_STAGES). export const OPERATION_GROUP_ORDER: readonly OperationGroup[] = [ "media", "transcript", @@ -346,6 +360,8 @@ export function groupLabel(group: OperationGroup): string { return "Digest"; case "speakers": return "Speakers"; + case "sync": + return "Sync"; } } @@ -1290,30 +1306,34 @@ export function laneYieldsToTranscription(lane: Lane): boolean { // - PER-CHANNEL VS CROSS-CHANNEL SCOPE. backfillBatch is one job per channel; // autoRunner arbitrates across every channel at once, which is the whole // point of its policy tree. -export type ExternalOperation = { - id: string; - label: string; - hint: string; - group: OperationGroup; - shortLabel: string; - costBasis: string; - lane: Lane; - dependsOn?: readonly string[]; - dispatch: "external"; - // The auto-queue runner that dispatches this, when one does. `dispatch` alone - // cannot answer it: download and transcription are `external` WITH a runner, - // and a future `transcode` would be `external` with none. A console that - // guessed from the id would hand transcode the transcription runner's - // controls — a live Start button over the wrong lane. - runner?: AutoQueueKind; - // Whether this operation applies to a channel AT ALL, off its config. Absent - // means every channel. The first (and so far only) taker is transcode: a - // youtube-handling channel or one with no audioFormat never has that work, - // and the three surfaces that gate the Transcode stage used to each spell - // `handling === "transcribe" && !!audioFormat` for themselves. Config-scoped - // on purpose — it is a fact about the channel's configuration, not about any - // video's state, which is what `state()` on a backfill operation answers. - appliesTo?: (config: ChannelConfig) => boolean; +// +// The SAME shape as a catalog entry, minus the two fields it cannot have: it is +// `external` by definition, and nothing in settings.json configures a download +// as an operation. Declared as a projection rather than a second field list so +// a new descriptor field is declared once and cannot go missing from one of the +// two — which is how `scope` and `trigger` arrive here for free. +export type ExternalOperation = Omit< + OperationDescriptor, + "dispatch" | "settingsBlock" +> & { dispatch: "external" }; + +// THE SYNC OPERATION. Catalogued so the board, the rail and /operations/sync +// come off the same table as everything else, and NOT in EXTERNAL_OPERATIONS — +// that list is the media-derived pipelines the rail draws bands for (editor +// buildBands.ts, EXTERNAL_BAND_IDS); sync has no per-video population. The lane +// is the shape download declares: syncAction runs through runPipelineAction on +// the per-platform download queue (pipelineActions.ts, downloadQueueKey). +export const SYNC_OPERATION: OperationDescriptor = { + id: "sync", + label: "Sync", + hint: "Noticing new videos: re-reading each channel's listing on its cadence and fetching what is new. Dispatched by the sync scheduler's heartbeat, and by hand per channel or all at once. A corpus that has stopped noticing new videos is not idle — it is broken.", + group: "sync", + shortLabel: "Sync", + costBasis: "one listing fetch per channel, over the network", + lane: { queueKey: "download:<platform>", contendsFor: "network" }, + dispatch: "external", + scope: "channel", + trigger: "cadence", }; export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [ @@ -1329,6 +1349,8 @@ export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [ // the dependency graph, not dispatch. lane: { queueKey: "download:<platform>", contendsFor: "network" }, dispatch: "external", + scope: "video", + trigger: "backlog", runner: "download", }, { @@ -1345,6 +1367,8 @@ export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [ lane: { queueKey: TRANSCRIPTION_QUEUE, contendsFor: "cpu" }, dependsOn: ["download"], dispatch: "external", + scope: "video", + trigger: "backlog", // No runner: nothing in the auto-queue feeds this. /operations/transcode // is the "no console here" panel, on purpose. appliesTo: (config) => @@ -1360,12 +1384,17 @@ export const EXTERNAL_OPERATIONS: readonly ExternalOperation[] = [ lane: { queueKey: TRANSCRIPTION_QUEUE, contendsFor: "gpu" }, dependsOn: ["download"], dispatch: "external", + scope: "video", + trigger: "backlog", runner: "transcription", }, ]; -// Every media-derived operation, dispatched here or not. The catalog — what a -// dependency id resolves against, and what a future scheduler enumerates. +// Every operation the console knows, dispatched here or not. The catalog — what +// a dependency id resolves against, and what the board, the rail and +// /operations/<id> enumerate. The sync scheduler is IN it now (SYNC_OPERATION), +// which is why `scope` and `trigger` exist: the entries are no longer all +// per-video, backlog-fed things. export type OperationDescriptor = { id: string; label: string; @@ -1376,18 +1405,40 @@ export type OperationDescriptor = { lane: Lane; dependsOn?: readonly string[]; dispatch: OperationDispatch; - // See ExternalOperation.runner. Absent for every backfill kind: those are - // dispatched by the sweep and the arbiter, not by a runner. + // See OperationScope. What this operation has a state FOR — a video for + // everything media-derived, a channel for sync. + scope: OperationScope; + // See OperationTrigger. What makes it run — a backlog for everything the + // sweep and the runners feed, a cadence for sync. + trigger: OperationTrigger; + // The auto-queue runner that dispatches this, when one does. `dispatch` alone + // cannot answer it: download and transcription are `external` WITH a runner, + // and a future `transcode` would be `external` with none. A console that + // guessed from the id would hand transcode the transcription runner's + // controls — a live Start button over the wrong lane. Absent for every + // backfill kind: those are dispatched by the sweep and the arbiter. + // + // Typed to the auto-queue kinds on purpose; the sync heartbeat is a runner in + // the IA doc's sense but not one of these, which is why /operations/sync is + // chosen off `trigger`. runner?: AutoQueueKind; - // See ExternalOperation.appliesTo. Absent means every channel. + // Whether this operation applies to a channel AT ALL, off its config. Absent + // means every channel. The first (and so far only) taker is transcode: a + // youtube-handling channel or one with no audioFormat never has that work, + // and the three surfaces that gate the Transcode stage used to each spell + // `handling === "transcribe" && !!audioFormat` for themselves. Config-scoped + // on purpose — it is a fact about the channel's configuration, not about any + // video's state, which is what `state()` on a backfill operation answers. appliesTo?: (config: ChannelConfig) => boolean; - // See Operation.settingsBlock. Absent for every external operation: nothing in - // settings.json configures a download or a transcode as an operation. + // See Operation.settingsBlock. Absent for every external operation but sync: + // nothing in settings.json configures a download or a transcode as an + // operation. settingsBlock?: OperationSettingsBlock; }; export function operationCatalog(): OperationDescriptor[] { return [ + SYNC_OPERATION, ...EXTERNAL_OPERATIONS, ...OPERATIONS.map((k) => ({ id: k.id, @@ -1399,6 +1450,11 @@ export function operationCatalog(): OperationDescriptor[] { lane: k.lane, dependsOn: k.dependsOn, dispatch: "backfill" as const, + // The registry is per-video by construction — `Operation.state(probe)` is + // a per-video probe — and every one of its entries is fed by a backlog of + // videos that lack the output. + scope: "video" as const, + trigger: "backlog" as const, settingsBlock: k.settingsBlock, })), ]; @@ -1462,6 +1518,8 @@ export function groupActionLabel(group: OperationGroup): string { return "digests"; case "speakers": return "speaker work"; + case "sync": + return "syncs"; } } diff --git a/common/lib/pauseGates.test.ts b/common/lib/pauseGates.test.ts @@ -96,9 +96,12 @@ test("pauseLaneFor answers for every catalog id, and transcode is null", () => { "attribution-diarized": "backfill", "attribution-text": "backfill", digest: "digest", + // Catalogued, and deliberately gateless: the sync scheduler's own `enabled` + // is its switch, and its queue key is neither sweep's. + sync: null, }; const ids = operationCatalog().map((o) => o.id); - assert.equal(ids.length, 7); + assert.equal(ids.length, 8); for (const id of ids) { assert.ok(id in expected, `catalog gained ${id} with no expected lane`); assert.equal(pauseLaneFor(id), expected[id], `pauseLaneFor(${id})`); diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts @@ -57,8 +57,10 @@ type _RunnerLanesArePauseLanes = AutoQueueKind extends PauseLane ? true : never; // hand it the transcription pause — a live Pause button over a lane that would // never dispatch it. Its own answer is null. // -// Walks operationCatalog() — all seven ids, external ones included — not -// OPERATION_BY_ID, which knows only the four registry entries. +// Walks operationCatalog() — all eight ids, external ones included — not +// OPERATION_BY_ID, which knows only the four registry entries. Sync is in that +// walk and its answer is null: the scheduler's `enabled` is its own switch, not +// a lane gate. export function pauseLaneFor(operationId: string): PauseLane | null { const op = operationCatalog().find((o) => o.id === operationId); if (!op) return null; diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts @@ -87,6 +87,12 @@ export const GROUP_STAGES: Record<OperationGroup, readonly StageId[]> = { transcript: ["transcribe"], digest: ["digest"], speakers: ["speakers"], + // Empty on purpose, and this is the paragraph above in practice: sync's + // channel surface is the hand-listed `playlist` bookend (JOB_KIND_TO_STAGE + // maps the sync job kind to it), which is a channel chore, not a stage this + // Record owns. Do not "finish" the derivation by giving sync a stage here — + // the channel page would grow a second, duplicate playlist card. + sync: [], }; export type StageTone = "neutral" | "attention" | "danger" | "running" | "ok"; diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -70,6 +70,11 @@ function summariseFreshness( // stations out in too. A reader moving between the two pages sees the same // left-to-right sequence, and adding a kind adds a column without touching this // file. +// +// Sync is catalogued but channel-scoped, so no column: `ids` is built from +// EXTERNAL_BAND_IDS plus the enabled backfill kinds and sync is in neither, and +// its group is not in OPERATION_GROUP_ORDER either — a column here would have +// to state a per-video coverage sync does not have. function pipelineColumns(operationIds: ReadonlyArray<string>): { ids: string[]; columns: PipelineColumn[]; diff --git a/editor/app/components/pipelines/buildBands.ts b/editor/app/components/pipelines/buildBands.ts @@ -94,6 +94,12 @@ export type BuildOperationBandsInput = { // definitions the channel transit line already uses for its Download and // Transcribe stations, so a corpus figure and a channel figure cannot disagree // about what "downloaded" means. +// +// Sync is catalogued beside them and still gets NO band, here or anywhere: its +// populations are channels, not videos (`scope: "channel"`), so every one of a +// band's five numbers would be a category error and the rail would draw +// "coverage unknown" over a hollow outline. Its rail row is composed instead — +// see SyncRailRow in OperationRail.tsx. function addExternalBands( bands: Map<string, OperationBand>, snapshot: ChannelSnapshot,