Archilyzer · Source

archilyzer

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

commit 2f215eb731fc785551a5ce100c0cc5c69ae70ef6
parent 26689d10b832efe9345baa20d62a27facfeb089e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 11:43:49 -0400

common: the sync scheduler asks the priority model, and orders focus first

S2 of plans/channel-priority.md. `selectDueChannels` and `buildScheduleView`
take the model and ask it exactly one question each.

The due loop gains `isChannelPaused(priority, slug, "sync")` beside — not
instead of — the legacy `excludeFromSync` skip, with the same silent
`continue`. Both stand until S5's migration has turned every flag into
`overrides: {sync: "paused"}` and deletes the flag; until then the flag is
still the only thing some channels carry. The EFFECTIVE tier for the "sync"
operation is what decides, so the two presets work as designed:
`{tier:"normal", overrides:{sync:"paused"}}` stops syncing and keeps
downloading, and `{tier:"paused", overrides:{sync:"normal"}}` is the
sync-only channel the operator asked for (omnibased).

The sort becomes tier, then rank, then most-overdue-first. Most-overdue-first
survives WITHIN a tier, so a focus channel due by a minute outranks a low
channel due by a day and `maxConcurrentSyncs` spends its slots on focus
first. Ties all the way down keep the input order — slug order from
`listChannelConfigs`, and a stable sort — which is exactly what this function
returned before the model existed.

`autoSyncEligible` follows the same predicate, so the sync console cannot
show a channel as eligible that the scheduler will not schedule.

Focus is passed IN, resolved. It is a compiled position, never a stored
tier, and a `{kind:"site"}` focus reads `transcripts/sites/*/site.json` —
I/O this pure module must not do. `runTick.ts` resolves it (only a site
focus pays the `listSites` read) and hands it in; `buildScheduleView` does
not need it at all, because focus changes the ORDER, not who is eligible.

The three editor call sites pass `settings.channelPriority`. Required rather
than optional on both inputs: an optional model with a default would compile
green and schedule paused channels forever, and no later slice wires these.

Nothing about the tick's timing, the sync operation or runYtdlp changes.

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

Diffstat:
Mcommon/jobs/syncScheduler.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Meditor/app/api/widget/sync/route.ts | 1+
Meditor/app/scheduler/runTick.ts | 24+++++++++++++++++++++++-
Meditor/app/scheduler/status.ts | 1+
4 files changed, 101 insertions(+), 11 deletions(-)

diff --git a/common/jobs/syncScheduler.ts b/common/jobs/syncScheduler.ts @@ -1,5 +1,12 @@ import type { ChannelConfig } from "../lib/channelConfig"; import type { SyncSchedulerSettings } from "../lib/settings"; +import { + type ChannelPriority, + effectiveTier, + isChannelPaused, + rankOf, + tierOrder, +} from "../lib/channelPriority"; import { resolveFullSweepIntervalMinutes } from "./deepSync"; import type { SchedulerSkip, SchedulerState } from "./syncSchedulerState"; @@ -20,10 +27,23 @@ export type SelectDueInput = { // Slugs that already have a running or queued sync job (from the registry). activeSlugs: ReadonlySet<string>; now: number; + // THE CHANNEL PRIORITY MODEL, asked for the "sync" operation and nothing + // else. It decides two things here and only two: which channels are skipped + // (effective tier `paused`) and what order the survivors come back in. + // Required rather than optional so a new caller cannot silently schedule a + // paused channel; `getSettings().channelPriority` always exists and is + // already sanitized (lib/settings.ts). + priority: ChannelPriority; + // The RESOLVED focus set. Focus is a compiled POSITION, never a stored tier, + // so it cannot come out of the model alone — a `{kind:"site"}` focus resolves + // against `transcripts/sites/*/site.json`, which is I/O this pure module must + // not do. The caller runs `resolveFocusSlugs` and hands the answer in. + focusSlugs?: readonly string[]; }; export type SelectDueResult = { - // Slugs that should be synced now, ordered most-overdue first. + // Slugs that should be synced now, ordered focus first, then by tier, then + // by rank, then most-overdue first. due: string[]; // Channels deliberately held back, with a human reason (for the run log). // The common "not yet due" case is intentionally omitted to keep the log @@ -79,11 +99,14 @@ export function nextEligibleAfterFailure( return now + backoffMinutes(failures, scheduler) * 60_000; } -// Core selection. Evaluates each channel against the config gates, the elapsed -// interval, the backoff window and the active-job set, then orders the winners -// most-overdue first so a concurrency-capped tick services the stalest channels. +// Core selection. Evaluates each channel against the config gates, the channel +// priority model, the elapsed interval, the backoff window and the active-job +// set, then orders the winners focus first, then by tier, then by rank, then +// most-overdue first — so a concurrency-capped tick spends its slots on the +// focused channels and services the stalest of them first. export function selectDueChannels(input: SelectDueInput): SelectDueResult { - const { channels, scheduler, state, activeSlugs, now } = input; + const { channels, scheduler, state, activeSlugs, now, priority } = input; + const focus = new Set(input.focusSlugs ?? []); if (!scheduler.enabled) return { due: [], skipped: [] }; if ( isInQuietHours(now, scheduler.quietHoursStart, scheduler.quietHoursEnd) @@ -92,11 +115,23 @@ export function selectDueChannels(input: SelectDueInput): SelectDueResult { } const skipped: SchedulerSkip[] = []; - const due: { slug: string; overdueMs: number }[] = []; + const due: { + slug: string; + overdueMs: number; + tier: number; + rank: number; + }[] = []; for (const { slug, config } of channels) { if (!config.url) continue; // not auto-sync material; no noise in the log + // BOTH SKIPS STAND until S5 deletes the legacy one. `excludeFromSync` is + // the flag the priority model replaces; the migration turns each of them + // into `overrides: {sync: "paused"}`, and until it has run the flag is + // still the only thing some channels carry. Silent `continue` either way — + // "this channel does not auto-sync" is configuration, not a hold worth a + // line in the run log. if (config.excludeFromSync) continue; + if (isChannelPaused(priority, slug, "sync")) continue; const interval = resolveIntervalMinutes(config, scheduler); if (interval <= 0) continue; // per-channel disabled @@ -119,10 +154,33 @@ export function selectDueChannels(input: SelectDueInput): SelectDueResult { const overdueMs = overdueAmount(config.lastSyncedAt, interval, now); if (overdueMs === null) continue; // not yet due - due.push({ slug, overdueMs }); + due.push({ + slug, + overdueMs, + // Focus outranks the stored tier; paused already left the loop above, so + // "focus wins over the stored tier, paused wins over focus" holds here + // by construction. + tier: tierOrder( + focus.has(slug) ? "focus" : effectiveTier(priority, slug, "sync"), + ), + // Unranked sorts last inside its tier, which is what an absent `rank` + // means everywhere else in the model. + rank: rankOf(priority, slug) ?? Number.POSITIVE_INFINITY, + }); } - due.sort((a, b) => b.overdueMs - a.overdueMs); + // TIER, THEN RANK, THEN MOST-OVERDUE-FIRST. Most-overdue-first survives + // *within* a tier, so a focus channel due by a minute outranks a low channel + // due by a day and the tick's `maxConcurrentSyncs` cap + // (editor/app/scheduler/runTick.ts) spends its slots on focus first. Ties all + // the way down keep the input order — `listChannelConfigs` returns slug + // order and Array.prototype.sort is stable — which is the order this + // function returned before the model existed. + due.sort((a, b) => { + if (a.tier !== b.tier) return a.tier - b.tier; + if (a.rank !== b.rank) return a.rank < b.rank ? -1 : 1; + return b.overdueMs - a.overdueMs; + }); return { due: due.map((d) => d.slug), skipped }; } @@ -132,7 +190,10 @@ export type ChannelScheduleView = { slug: string; name: string | null; // True when this channel is eligible for auto-sync (scheduler on, has a url, - // not excluded, and a positive resolved interval). + // not excluded, not paused for sync by the channel priority model, and a + // positive resolved interval). THE SAME PREDICATE `selectDueChannels` skips + // on, so the sync console can never show a channel as eligible that the + // scheduler will not schedule. autoSyncEligible: boolean; intervalMinutes: number; // resolved; 0 = disabled inheritsInterval: boolean; // using the global default vs a per-channel value @@ -164,8 +225,12 @@ export function buildScheduleView(input: { scheduler: SyncSchedulerSettings; state: SchedulerState; now: number; + // The same model `selectDueChannels` takes, for the same reason: the + // projection must agree with the scheduler about who is skipped. Focus is + // not needed — it changes the ORDER, not who is eligible. + priority: ChannelPriority; }): ChannelScheduleView[] { - const { channels, scheduler, state, now } = input; + const { channels, scheduler, state, now, priority } = input; return channels.map(({ slug, config }) => { const interval = resolveIntervalMinutes(config, scheduler); const sweepInterval = resolveFullSweepIntervalMinutes(config, scheduler); @@ -194,6 +259,7 @@ export function buildScheduleView(input: { scheduler.enabled && !!config.url && !config.excludeFromSync && + !isChannelPaused(priority, slug, "sync") && interval > 0, intervalMinutes: interval, inheritsInterval: config.syncIntervalMinutes === undefined, diff --git a/editor/app/api/widget/sync/route.ts b/editor/app/api/widget/sync/route.ts @@ -187,6 +187,7 @@ export async function buildWidgetSyncPayload(): Promise<WidgetSyncPayload> { scheduler: settings.syncScheduler, state, now, + priority: settings.channelPriority, }); const eligible = view.filter((v) => v.autoSyncEligible); let nextRunAt: number | null = null; diff --git a/editor/app/scheduler/runTick.ts b/editor/app/scheduler/runTick.ts @@ -24,6 +24,8 @@ import { type SchedulerState, } from "yt-dlp-transcript-common/jobs/syncSchedulerState"; import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; +import { resolveFocusSlugs } from "yt-dlp-transcript-common/lib/channelPriority"; +import { listSites } from "yt-dlp-transcript-common/lib/site"; import { syncAction } from "../channels/[slug]/pipelineActions"; import { fetchPostsAction } from "../channels/[slug]/socialActions"; // STORAGE CHORES riding this heartbeat because it is the one timer the editor @@ -114,12 +116,31 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { slug: c.slug, config: c.config, })); + // The focus set is the only half of the priority model that costs I/O, and + // ONLY a `{kind:"site"}` focus pays it: `resolveFocusSlugs` reads a site's + // `channels[]` so a focus on a site tracks its membership instead of + // freezing a list. `{kind:"none"}` and `{kind:"channels"}` resolve from the + // document alone. No cache here — the tick runs on the heartbeat, not per + // grant, so one `listSites()` per tick is not a cost worth memoizing. + const priority = settings.channelPriority; + const siteChannels: Record<string, string[]> = {}; + if (priority.focus.kind === "site") { + for (const site of listSites(paths)) { + siteChannels[site.siteId] = site.channels.map((c) => c.slug); + } + } const { due, skipped } = selectDueChannels({ channels, scheduler, state, activeSlugs, now, + priority, + focusSlugs: resolveFocusSlugs( + priority, + siteChannels, + channels.map((c) => c.slug), + ), }); // Concurrency cap doubles as the stagger: queue at most (cap - running) @@ -134,7 +155,8 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { const bySlug = new Map(channels.map((c) => [c.slug, c.config])); for (const slug of toQueue) { // The scheduler's ELIGIBILITY rules are source-agnostic (url + - // excludeFromSync + interval + lastSyncedAt), but the dispatch is not: a + // excludeFromSync + sync tier + interval + lastSyncedAt), but the + // dispatch is not: a // social channel must run a post fetch, not a yt-dlp video sync against // its profile URL. const result = isSocialChannel(bySlug.get(slug)) diff --git a/editor/app/scheduler/status.ts b/editor/app/scheduler/status.ts @@ -41,6 +41,7 @@ export async function buildSchedulerStatusPayload(): Promise<SchedulerStatusPayl scheduler: settings.syncScheduler, state, now, + priority: settings.channelPriority, }); return { now,