Archilyzer · Source

archilyzer

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

commit 34f1af527cd9356195bbafea59789bc56506b6ee
parent f38ebca213269686ff379b3afb3791bc39a3ee80
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 12:35:46 -0400

channels: excludeFromSync is deleted

The field said "stop syncing, keep everything else". That is exactly one entry
in the priority document's per-operation override map —
`{tier:<base>, overrides:{sync:"paused"}}` — and the map can say it for any
operation, not just this one. So the flag goes, with its sanitizer, its
action, its toggle and both legacy skip branches.

Gone: `ChannelConfig.excludeFromSync` and its sanitizer clause,
`toggleChannelSyncInclusionAction`, `ChannelSyncToggle.tsx`,
`selectDueChannels`' legacy `continue`, `autoSyncEligible`'s legacy term,
`syncAllChannelsAction`'s "excluded from sync all" skip, and `stationWorkFor`'s
"excluded from sync" reason. `isChannelPaused(model, slug, "sync")` is the one
question all five now ask. `excludeFromBuild` and `excludeFromCleanup` are
untouched — different axes (publishing, and the /cleanup total).

THE PARSER DROPS THE KEY RATHER THAN CARRYING IT, which is what makes an
un-migrated config.json still parse: it comes back saying nothing about sync,
so the channel syncs — what it did before the flag was invented. That is also
why the migration script reads the raw config.json for the flag and why the
editor's legacy seed passes `{}` to `channelPriorityFromLegacy`: the seed is
after the lane ORDER, which lives in the stored trees, and the sync overrides
are the script's half.

`channel-sync-toggle.spec.ts` went with S3, which removed the control.
`syncScheduler.test.ts`'s legacy-flag case is now the assertion that a
leftover key IS dropped by the parser and skips nothing.

common 985/985; tsc clean in common, editor, export, mcp.

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

Diffstat:
Mcommon/bin/migrate-channel-priority.ts | 49++++++++++++++++++++++++++++++++++++++++++-------
Mcommon/controller/fetchPosts.ts | 4+++-
Mcommon/jobs/syncScheduler.test.ts | 35+++++++++++++++++++++--------------
Mcommon/jobs/syncScheduler.ts | 9+++------
Mcommon/lib/channelConfig.ts | 15++++++++++-----
Mcommon/lib/channelPriority.ts | 11++++++++---
Meditor/app/channels/actions.ts | 37++++++++++++++-----------------------
Deditor/app/channels/components/ChannelSyncToggle.tsx | 45---------------------------------------------
Meditor/app/channels/lib/channelGroupSections.test.ts | 14++++----------
Meditor/app/channels/lib/channelGroupSections.ts | 12+++---------
Meditor/app/scheduler/runTick.ts | 5++---
11 files changed, 110 insertions(+), 126 deletions(-)

diff --git a/common/bin/migrate-channel-priority.ts b/common/bin/migrate-channel-priority.ts @@ -1,9 +1,8 @@ #!/usr/bin/env tsx import path from "node:path"; -import { readFile, rename, writeFile } from "node:fs/promises"; +import { readdir, readFile, rename, writeFile } from "node:fs/promises"; import { getPaths } from "../lib/paths"; import { getSettings, writeSettings } from "../lib/settings"; -import { listChannelConfigs } from "../controller/channels"; import { siteChannelIndex } from "../lib/site"; import { channelPriorityFromLegacy, @@ -39,6 +38,13 @@ import { parseFlags } from "./_parseFlags"; // configs; `getSettings` is synchronous and reads one file. Running it from // there would put 68 reads on every settings read in the process. // +// WHY IT READS THE CONFIGS RAW, and not through `listChannelConfigs`. +// `excludeFromSync` is DELETED from `ChannelConfig` in the same slice, and +// `parseChannelConfig` is allow-list style — so the parser now drops the very +// key this migration exists to read. The raw file is the only place the flag +// still exists, which is also why the clearing pass below is a raw key delete +// rather than a parse-and-rewrite. +// // IDEMPOTENT, TWICE OVER. `channelPriorityFromLegacy` returns the STORED // document untouched once any lane root carries a `prio-*` id — compiled // channel leaves are bare channel leaves, so a second run would otherwise @@ -61,6 +67,31 @@ const flags = parseFlags(process.argv.slice(2)); const dryRun = flags["dry-run"] === "true"; const paths = getPaths(); +// One channel, as the migration needs it: the slug and the RAW parsed JSON of +// its config.json. A directory with no readable config.json is skipped, the +// same channels `listChannelConfigs` would have returned. +type RawChannel = { slug: string; raw: Record<string, unknown> }; + +async function readRawChannels(): Promise<RawChannel[]> { + const entries = await readdir(paths.channelsDir, { + withFileTypes: true, + }).catch(() => []); + const out: RawChannel[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const file = path.join(paths.channelsDir, entry.name, "config.json"); + let raw: unknown; + try { + raw = JSON.parse(await readFile(file, "utf8")); + } catch { + continue; + } + if (!raw || typeof raw !== "object") continue; + out.push({ slug: entry.name, raw: raw as Record<string, unknown> }); + } + return out.sort((a, b) => a.slug.localeCompare(b.slug)); +} + // Which legacy fact produced this channel's entry, for the table. The point of // the column is that an operator can check the migration against the two lists // they wrote by hand, so it names the LANE and the position, not just "a rank". @@ -173,14 +204,18 @@ async function clearExcludeFromSync(slug: string): Promise<boolean> { async function main(): Promise<void> { const settings = getSettings(); - const configs = await listChannelConfigs(paths); - const slugs = configs.map((c) => c.slug); + const channels = await readRawChannels(); + const slugs = channels.map((c) => c.slug); + // `LegacyChannelRow` is structural and names only the one key, so the raw + // objects satisfy it directly. + const configs = channels.map(({ slug, raw }) => ({ + slug, + config: { excludeFromSync: raw.excludeFromSync === true }, + })); const transcription = bareLeaves(settings.autoQueue.transcription?.root); const download = bareLeaves(settings.autoQueue.download?.root); const excludedSlugs = new Set( - configs - .filter((c) => (c.config as { excludeFromSync?: boolean }).excludeFromSync) - .map((c) => c.slug), + configs.filter((c) => c.config.excludeFromSync).map((c) => c.slug), ); console.log(`settings: ${paths.settingsFile}`); diff --git a/common/controller/fetchPosts.ts b/common/controller/fetchPosts.ts @@ -177,7 +177,9 @@ export async function fetchPosts( // Reuse the existing lastSyncedAt field so the scheduler // (common/jobs/syncScheduler.ts) paces social channels with zero changes — - // it keys only off url / excludeFromSync / syncIntervalMinutes / lastSyncedAt. + // it keys only off url / syncIntervalMinutes / lastSyncedAt, plus the + // channel-priority document's `sync` tier (`isChannelPaused(model, slug, + // "sync")`), which is where the retired `excludeFromSync` flag went. await writeChannelConfig(paths, slug, { ...config, lastSyncedAt: new Date().toISOString(), diff --git a/common/jobs/syncScheduler.test.ts b/common/jobs/syncScheduler.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import type { ChannelConfig } from "../lib/channelConfig"; +import { parseChannelConfig, type ChannelConfig } from "../lib/channelConfig"; import { sanitizeChannelPriority, type ChannelPriority, @@ -191,17 +191,22 @@ test("focus does not rescue a channel paused for sync", () => { assert.deepEqual(r.due, ["other"]); }); -test("the legacy excludeFromSync flag still skips, beside the new predicate", () => { - // S5 deletes this flag. Until its migration has run, it is the only thing - // some channels carry, so both skips have to stand. - const r = due( - [ - channel("legacy", 1 * DAY, { excludeFromSync: true }), - channel("modern", 1 * HOUR), - ], - model({ channels: {} }), - ); - assert.deepEqual(r.due, ["modern"]); +test("an un-migrated config carries nothing the scheduler can skip on", () => { + // `excludeFromSync` is DELETED (S5), and `parseChannelConfig` is allow-list + // style, so a config.json that still spells the key parses to a channel that + // says nothing about sync at all. That is the correct reading: the migration + // is what turns the flag into `overrides: {sync:"paused"}`, and until it has + // run the channel syncs — which is what it did before the flag was invented. + const parsed = parseChannelConfig({ + handling: "youtube", + url: "https://example.com/c", + excludeFromSync: true, + lastSyncedAt: new Date(NOW - HOUR - 1 * DAY).toISOString(), + }); + assert.ok(parsed); + assert.equal("excludeFromSync" in parsed, false); + const r = due([{ slug: "stale", config: parsed }], model({ channels: {} })); + assert.deepEqual(r.due, ["stale"]); assert.deepEqual(r.skipped, []); }); @@ -211,7 +216,6 @@ test("the schedule projection agrees with the scheduler about who is skipped", ( channel("sync-paused", 1 * DAY), channel("base-paused", 1 * DAY), channel("sync-only", 1 * HOUR), - channel("legacy", 1 * DAY, { excludeFromSync: true }), channel("no-url", 1 * DAY, { url: undefined }), channel("interval-off", 1 * DAY, { syncIntervalMinutes: 0 }), ]; @@ -239,7 +243,10 @@ test("the schedule projection agrees with the scheduler about who is skipped", ( test("the projection is unchanged by an empty priority document", () => { const view = buildScheduleView({ - channels: [channel("a", 1 * HOUR), channel("b", 1 * DAY, { excludeFromSync: true })], + channels: [ + channel("a", 1 * HOUR), + channel("b", 1 * DAY, { syncIntervalMinutes: 0 }), + ], scheduler: scheduler(), state: emptySchedulerState(), now: NOW, diff --git a/common/jobs/syncScheduler.ts b/common/jobs/syncScheduler.ts @@ -124,13 +124,11 @@ export function selectDueChannels(input: SelectDueInput): SelectDueResult { 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 — + // ONE SKIP. `excludeFromSync` is gone (S5); the document's `sync` tier is + // what it became, and the migration turned each of the 15 channels that + // carried the flag into `overrides: {sync: "paused"}`. Silent `continue` — // "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); @@ -258,7 +256,6 @@ export function buildScheduleView(input: { autoSyncEligible: scheduler.enabled && !!config.url && - !config.excludeFromSync && !isChannelPaused(priority, slug, "sync") && interval > 0, intervalMinutes: interval, diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts @@ -110,7 +110,14 @@ export type ChannelConfig = { // the cheap newest-first paged walk. lastFullSweepAt?: string; excludeFromBuild?: boolean; - excludeFromSync?: boolean; + // `excludeFromSync` IS GONE. It said "stop syncing, keep everything else", + // which is exactly what `channelPriority`'s per-operation override map says + // — `{tier:<base>, overrides:{sync:"paused"}}` — and the model can say it + // for every operation, not just this one. The sanitizer below drops the key + // rather than carrying it, so an un-migrated config.json still parses: the + // migration (common/bin/migrate-channel-priority.ts) reads it from the RAW + // file, not through this parser, precisely because this parser no longer + // knows the word. // Opt this channel OUT of the aggregate "cleanable data" total shown on the // /cleanup page and its sidebar badge. The per-channel cleanup sweeps remain // fully available; this flag only removes the channel's reclaimable bytes from @@ -123,7 +130,8 @@ export type ChannelConfig = { // undefined -> inherit the global SiteSettings default interval // 0 -> auto-sync disabled for this channel (still manually syncable) // > 0 -> sync this often (clamped to [SYNC_INTERVAL_MIN/MAX_MINUTES]) - // A missing `url` or `excludeFromSync` also disables auto-sync. + // A missing `url` also disables auto-sync, as does a `sync` tier of `paused` + // in the channel-priority document (common/lib/channelPriority.ts). syncIntervalMinutes?: number; // Full-sweep cadence for this channel (see common/jobs/deepSync.ts). A sync // upgrades itself to a full sweep when @@ -275,9 +283,6 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null { if (typeof r.excludeFromBuild === "boolean") { config.excludeFromBuild = r.excludeFromBuild; } - if (typeof r.excludeFromSync === "boolean") { - config.excludeFromSync = r.excludeFromSync; - } if (typeof r.excludeFromCleanup === "boolean") { config.excludeFromCleanup = r.excludeFromCleanup; } diff --git a/common/lib/channelPriority.ts b/common/lib/channelPriority.ts @@ -617,9 +617,14 @@ export function focusSummary( // --- The migration's pure half ---------------------------------------------- -// Just enough of a channel row to migrate it. `listChannelConfigs`' rows -// satisfy this structurally, so the caller hands them straight over and this -// file does not have to name `ChannelConfig`. +// Just enough of a channel row to migrate it, named structurally so this file +// does not have to import `ChannelConfig`. +// +// NOTE the one key is gone from `ChannelConfig` (S5) and `parseChannelConfig` +// drops it, so a PARSED config no longer satisfies this usefully — the +// migration script reads the raw config.json for it +// (common/bin/migrate-channel-priority.ts), and the editor's legacy seed +// passes `{}` because it is after the lane ORDER, not the flag. export type LegacyChannelRow = { slug: string; config: { excludeFromSync?: boolean | undefined }; diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -451,23 +451,6 @@ export async function toggleChannelBuildInclusionAction( return undefined; } -export async function toggleChannelSyncInclusionAction( - slug: string, -): Promise<ActionResult> { - const paths = getPaths(); - const existing = await readChannelConfig(paths, slug); - if (!existing) return { error: `Channel "${slug}" not found` }; - const next = { ...existing }; - if (existing.excludeFromSync) { - delete next.excludeFromSync; - } else { - next.excludeFromSync = true; - } - await writeChannelConfig(paths, slug, next); - revalidatePath("/channels"); - return undefined; -} - // Toggle whether this channel's reclaimable bytes count toward the aggregate // "cleanable data" total on the /cleanup page (and its sidebar badge). The // cleanup sweeps themselves stay available regardless; this only flips the @@ -515,10 +498,10 @@ export async function syncAllChannelsAction( // THE SAME ANSWER THE SCHEDULER GIVES, asked of the `sync` OPERATION. // // A manual pool sweep and the automatic one must agree about which channels - // are in the pool: the group Sync buttons already ask the priority document - // through `stationWorkFor`, and this slice removed the only control that - // could set the flag this loop used to read. So the model is asked here too, - // BESIDE the legacy flag — S5 deletes the second branch with the field. + // are in the pool: the group Sync buttons ask the priority document through + // `stationWorkFor` and the scheduler asks it in `selectDueChannels`, so this + // loop asks the same question. It is the ONLY question now — S5 deleted + // `excludeFromSync` and migrated the 15 channels that carried it. const priority = getSettings().channelPriority; const slugs = channels.map((c) => c.slug); const focus = new Set( @@ -542,7 +525,6 @@ export async function syncAllChannelsAction( const config = bySlug.get(slug); if (!config?.url) return "no url"; if (isChannelPaused(priority, slug, "sync")) return "paused for sync"; - if (config.excludeFromSync) return "excluded from sync all"; if (active.has(slug)) return "already running"; return null; }, @@ -814,7 +796,16 @@ export async function saveChannelPriorityAction( edit.kind !== "recompile" && isDefaultChannelPriority(stored) && !hasCompiledLaneRoots(settings.autoQueue) - ? channelPriorityFromLegacy(configs, settings.autoQueue) + ? channelPriorityFromLegacy( + // THE SEED READS THE LANE ORDERS, NOT THE FLAG. `excludeFromSync` is + // deleted and `parseChannelConfig` drops the key, so nothing the + // editor can read still carries it — turning those 15 channels into + // `overrides: {sync:"paused"}` is the migration script's half, off + // the raw config.json. What this seed is for is the ORDER, and the + // order lives in the stored trees. + configs.map((c) => ({ slug: c.slug, config: {} })), + settings.autoQueue, + ) : stored; const next = sanitizeChannelPriority(applyPriorityEdit(base, edit)); // A DEFAULT DOCUMENT COMPILES NOTHING, which is the same rule diff --git a/editor/app/channels/components/ChannelSyncToggle.tsx b/editor/app/channels/components/ChannelSyncToggle.tsx @@ -1,45 +0,0 @@ -"use client"; - -import { toggleChannelSyncInclusionAction } from "../actions"; - -export function ChannelSyncToggle({ - slug, - excluded, -}: { - slug: string; - excluded: boolean; -}) { - return ( - <form - action={async () => { - await toggleChannelSyncInclusionAction(slug); - }} - > - <button - type="submit" - aria-label={`toggle sync inclusion for ${slug}`} - aria-pressed={!excluded} - title={ - excluded - ? "Excluded from Sync all. Click to include." - : "Included in Sync all. Click to exclude." - } - className={ - "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium border transition-colors " + - (excluded - ? "border-border text-muted-foreground hover:bg-muted" - : "border-success/30 bg-success-soft text-success hover:bg-success/20") - } - > - <span - aria-hidden="true" - className={ - "inline-block h-1.5 w-1.5 rounded-full " + - (excluded ? "bg-muted-foreground" : "bg-success") - } - /> - {excluded ? "Skipped" : "Included"} - </button> - </form> - ); -} diff --git a/editor/app/channels/lib/channelGroupSections.test.ts b/editor/app/channels/lib/channelGroupSections.test.ts @@ -260,20 +260,14 @@ test("a social channel is eligible for sync only", () => { assert.deepEqual(s.speakers.eligible, []); }); -test("sync skips a channel with no url or excluded from sync", () => { +test("sync skips a channel with no url", () => { const sections = build( - siteOf({ - channels: [{ slug: "ok" }, { slug: "nourl" }, { slug: "excluded" }], - }), - [ - channel("ok"), - channel("nourl", { url: undefined }), - channel("excluded", { excludeFromSync: true }), - ], + siteOf({ channels: [{ slug: "ok" }, { slug: "nourl" }] }), + [channel("ok"), channel("nourl", { url: undefined })], ); assert.deepEqual(sections[0].sync.eligible, ["ok"]); // Download needs a url too. - assert.deepEqual(sections[0].download.eligible.sort(), ["excluded", "ok"]); + assert.deepEqual(sections[0].download.eligible.sort(), ["ok"]); }); // The priority document answers the same question the flag does, for the `sync` diff --git a/editor/app/channels/lib/channelGroupSections.ts b/editor/app/channels/lib/channelGroupSections.ts @@ -130,12 +130,9 @@ export function stationWorkFor( if (!config.url) return { eligible: false, work: 0, reason: "no url" }; // THE PAUSED SECTION. The tier document is asked for the `sync` OPERATION // — `isChannelPaused(model, slug, "sync")` — which is precisely what the - // retired `excludeFromSync` flag meant, read the other way round. - // - // The flag is still read BESIDE it, not instead of it: until S5's migration - // rewrites the 15 channels that carry it, dropping the legacy read here - // would silently re-include every one of them in a group's "Sync" sweep. - // S5 deletes the second branch with the field. + // deleted `excludeFromSync` flag meant, read the other way round, and is + // now the only thing asked: S5 deleted the flag and migrated the 15 + // channels that carried it. // `?? defaultChannelPriority()` for the same reason `isGateHeld` reaches // its key with optional chaining: this function is handed partial settings // objects by unit tests and by any caller that has not been through @@ -144,9 +141,6 @@ export function stationWorkFor( if (isChannelPaused(priority, slug, "sync")) { return { eligible: false, work: 0, reason: "paused for sync" }; } - if (config.excludeFromSync) { - return { eligible: false, work: 0, reason: "excluded from sync" }; - } return { eligible: true, work: 0 }; } diff --git a/editor/app/scheduler/runTick.ts b/editor/app/scheduler/runTick.ts @@ -152,9 +152,8 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { const queued: string[] = []; 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 + sync tier + interval + lastSyncedAt), but the - // dispatch is not: a + // The scheduler's ELIGIBILITY rules are source-agnostic (url + 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))