Archilyzer · Source

archilyzer

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

commit 67c7f85de076c0fc4c5070e4deb57ac8380a6626
parent eda647537d08237a39a0e27b9a41db81af240212
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 14 Sep 2026 17:08:40 -0400

views: the sync schedule is a projection, not a reader

`buildSchedulerStatusPayload` was four reads and one projection in one
function, which is why it could only ever be called from a request: it opened
with getPaths()/getSettings(), awaited the scheduler state and the channel
list, read the clock, and only then folded the five into a payload.

The fold moves to `common/views/schedulerStatus.ts` and takes all five as
arguments. Nothing about the payload changes — same type name, same fields,
same values, `channels` is still exactly `buildScheduleView`'s output (which
stays in jobs/, where the due-date rule belongs).

`editor/app/scheduler/status.ts` keeps the old path, the old exported names and
the same values, and is now the reads alone. It is also where
`resolveHeartbeatSeconds` has to stay: it lives beside `runTick`, a RUNNER, and
a view that named one would be a view that could start work.

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

Diffstat:
Acommon/views/schedulerStatus.test.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/schedulerStatus.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/scheduler/status.ts | 51+++++++++++++++------------------------------------
3 files changed, 149 insertions(+), 36 deletions(-)

diff --git a/common/views/schedulerStatus.test.ts b/common/views/schedulerStatus.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { defaultSyncScheduler } from "../lib/settings"; +import { defaultChannelPriority } from "../lib/channelPriority"; +import { buildScheduleView } from "../jobs/syncScheduler"; +import { emptySchedulerState } from "../jobs/syncSchedulerState"; +import type { ChannelConfig } from "../lib/channelConfig"; +import { buildSchedulerStatusPayload } from "./schedulerStatus"; + +// The payload is four pass-throughs and one projection, and that is the whole +// point: everything it reports is either handed to it or is `buildScheduleView` +// applied to what was handed to it. A second copy of the due-date rule here is +// exactly what this shape exists to prevent. + +const config = (over: Partial<ChannelConfig> = {}): ChannelConfig => + ({ name: "a channel", url: "https://example.com/c", ...over }) as ChannelConfig; + +function inputs() { + const settings = { + syncScheduler: { ...defaultSyncScheduler(), enabled: true }, + channelPriority: defaultChannelPriority(), + }; + const state = emptySchedulerState(); + state.runs = [{ at: 1_000, queued: ["alpha"], skipped: [] }]; + const channels = [ + { slug: "alpha", config: config({ lastSyncedAt: "2026-01-01T00:00:00Z" }) }, + { slug: "beta", config: config() }, + ]; + return { + settings, + now: 1_700_000_000_000, + state, + channels, + heartbeatSeconds: 45, + }; +} + +test("now, scheduler, runs and heartbeatSeconds pass straight through", () => { + const i = inputs(); + const payload = buildSchedulerStatusPayload(i); + assert.equal(payload.now, i.now); + assert.equal(payload.scheduler, i.settings.syncScheduler); + assert.equal(payload.runs, i.state.runs); + // The env override is resolved by the shell, beside the heartbeat runner — + // whatever it says arrives here verbatim, including 0 ("external cron only"). + assert.equal(payload.heartbeatSeconds, 45); + assert.equal(buildSchedulerStatusPayload({ ...i, heartbeatSeconds: 0 }).heartbeatSeconds, 0); +}); + +test("channels is buildScheduleView's output, not a second projection", () => { + const i = inputs(); + const payload = buildSchedulerStatusPayload(i); + assert.deepEqual( + payload.channels, + buildScheduleView({ + channels: i.channels, + scheduler: i.settings.syncScheduler, + state: i.state, + now: i.now, + priority: i.settings.channelPriority, + }), + ); + assert.deepEqual( + payload.channels.map((c) => c.slug), + ["alpha", "beta"], + ); + // And it really is the scheduler's own verdict: a channel that has never + // synced is due now. + assert.equal(payload.channels[1].overdue, true); +}); diff --git a/common/views/schedulerStatus.ts b/common/views/schedulerStatus.ts @@ -0,0 +1,64 @@ +import type { SyncSchedulerSettings } from "../lib/settings"; +import { + buildScheduleView, + type ChannelEntry, + type ChannelScheduleView, +} from "../jobs/syncScheduler"; +import type { + SchedulerRun, + SchedulerState, +} from "../jobs/syncSchedulerState"; +import type { ChannelPriority } from "../lib/channelPriority"; + +// THE READ-ONLY "SYNC SCHEDULE" VIEW. +// +// Shared by the SSR page and the /api/scheduler/status poll so the two can +// never drift. Everything it needs arrives as an argument: the settings, the +// scheduler state read off disk, the channel list, the clock and the effective +// heartbeat cadence. `buildScheduleView` is a pure projection in jobs/ and is +// the one definition of "when is this channel next due" — the console asks the +// scheduler rather than re-deriving it. + +export type SchedulerStatusPayload = { + now: number; + scheduler: SyncSchedulerSettings; + channels: ChannelScheduleView[]; + runs: SchedulerRun[]; + // Effective internal-heartbeat cadence in seconds (env override applied), so + // the UI can show whether ticks are internally driven. 0 = no internal timer + // (awaiting an external cron heartbeat). + heartbeatSeconds: number; +}; + +export type SchedulerStatusInputs = { + settings: { + syncScheduler: SyncSchedulerSettings; + channelPriority: ChannelPriority; + }; + now: number; + state: SchedulerState; + channels: ReadonlyArray<ChannelEntry>; + // Resolved by the shell: it reads the env override and imports the heartbeat + // module, which owns a RUNNER. A view never names a runner. + heartbeatSeconds: number; +}; + +export function buildSchedulerStatusPayload( + inputs: SchedulerStatusInputs, +): SchedulerStatusPayload { + const { settings, now, state, channels, heartbeatSeconds } = inputs; + const view = buildScheduleView({ + channels, + scheduler: settings.syncScheduler, + state, + now, + priority: settings.channelPriority, + }); + return { + now, + scheduler: settings.syncScheduler, + channels: view, + runs: state.runs, + heartbeatSeconds, + }; +} diff --git a/editor/app/scheduler/status.ts b/editor/app/scheduler/status.ts @@ -1,53 +1,32 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { - getSettings, - type SyncSchedulerSettings, -} from "yt-dlp-transcript-common/lib/settings"; -import { - buildScheduleView, - type ChannelScheduleView, -} from "yt-dlp-transcript-common/jobs/syncScheduler"; -import { - readSchedulerState, - type SchedulerRun, -} from "yt-dlp-transcript-common/jobs/syncSchedulerState"; + buildSchedulerStatusPayload as build, + type SchedulerStatusPayload, +} from "yt-dlp-transcript-common/views/schedulerStatus"; +import { readSchedulerState } from "yt-dlp-transcript-common/jobs/syncSchedulerState"; import { resolveHeartbeatSeconds } from "./heartbeat"; -export type SchedulerStatusPayload = { - now: number; - scheduler: SyncSchedulerSettings; - channels: ChannelScheduleView[]; - runs: SchedulerRun[]; - // Effective internal-heartbeat cadence in seconds (env override applied), so - // the UI can show whether ticks are internally driven. 0 = no internal timer - // (awaiting an external cron heartbeat). - heartbeatSeconds: number; -}; +export type { SchedulerStatusPayload }; -// Assemble the read-only "Sync schedule" view. Shared by the SSR page and the -// /api/scheduler/status poll so they never drift. +// THE SHELL. The payload is `common/views/schedulerStatus.ts`; this reads the +// four things it takes and nothing else. `resolveHeartbeatSeconds` is why the +// split lands here rather than one file further down: it lives beside +// `runTick`, a RUNNER, and a view may not name one. export async function buildSchedulerStatusPayload(): Promise<SchedulerStatusPayload> { const paths = getPaths(); const settings = getSettings(); const state = await readSchedulerState(paths); - const now = Date.now(); const channels = (await listChannelConfigs(paths)).map((c) => ({ slug: c.slug, config: c.config, })); - const view = buildScheduleView({ - channels, - scheduler: settings.syncScheduler, + return build({ + settings, + now: Date.now(), state, - now, - priority: settings.channelPriority, - }); - return { - now, - scheduler: settings.syncScheduler, - channels: view, - runs: state.runs, + channels, heartbeatSeconds: resolveHeartbeatSeconds(), - }; + }); }