Archilyzer · Source

archilyzer

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

commit cec46abda0a90ed5820ffa3ebc41a85a61a50735
parent 68a1e9112c02db702641a7b619d8e8fabbeab56d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 11:59:49 -0400

editor: Sync all asks the priority document, and a failed priority write says so

THE BLOCKER. `syncAllChannelsAction` still gated on `config.excludeFromSync`
alone while the group Sync buttons — through `stationWorkFor` — already asked
the model, and this slice removed the only control that could set the flag that
loop read. So a channel the operator had just paused was still swept by the
header button, with no way left to stop it. The manual pool sweep now asks
`isChannelPaused(priority, slug, "sync")` BESIDE the legacy flag (skip reason
`paused for sync`; the flag keeps `excluded from sync all` until S5 deletes it),
and the candidate list is ordered focus → tier → rank → slug the way the
scheduler orders its due list: `queueForSlugs` runs the list in order and each
sync takes a platform-queue slot, so on a 68-channel pool the order IS the
priority. `siteChannelIndex` — already the writer's one site-membership reader —
resolves the focus set here too; there is no second index builder.

"S5 owns actions.ts" was the wrong reading: S3 adds ~180 lines to this file
already, and the plan only sequences S5 after S3 to keep the two off it at the
same time.

A FAILED WRITE MUST SAY SO. `saveChannelPriorityAction` returns `{error}` BEFORE
it revalidates, so a discarded result left the optimistic value snapping back to
the server's — indistinguishable from "the click did nothing". Every control now
surfaces it in the shape `SyncAllChannelsButton` already uses on this page:
role="alert", a labelled span, text-destructive. One line per row
(`priority error for <slug>`) and one per bar (`focus error`, `priority bulk
error`), with the two bars sharing one runner so there is one place a result is
read. The held-reason span gains role="note" — it is an aria-label on bare text
otherwise, which nothing announces.

The coverage `channel-sync-toggle.spec.ts` took with it comes back, restated
against the tier the flag became: paused slow-a, sweep the pool, assert
`Queued 1 · skipped 1` and `slow-a: paused for sync` in the tooltip.

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

Diffstat:
Meditor/app/channels/actions.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++------------
Meditor/app/channels/components/ChannelBulkBar.tsx | 75+++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Meditor/app/channels/components/ChannelTierSelect.tsx | 28+++++++++++++++++++++++++---
Meditor/e2e/channel-priority.spec.ts | 24++++++++++++++++++++++++
4 files changed, 148 insertions(+), 39 deletions(-)

diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -42,8 +42,12 @@ import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes"; import { compileLanes, DEFAULT_CHANNEL_TIER, + effectiveTier, + isChannelPaused, + rankOf, resolveFocusSlugs, sanitizeChannelPriority, + tierOrder, type ChannelFocus, type ChannelPriority, type ChannelPriorityEntry, @@ -468,6 +472,15 @@ export async function toggleChannelCleanupInclusionAction( return undefined; } +// Unranked sorts AFTER every ranked sibling — `orderWithin`'s rule in the +// compiler, not a plain numeric compare, which would put a missing rank first. +function compareSyncRank(a: number | null, b: number | null): number { + if (a === b) return 0; + if (a === null) return 1; + if (b === null) return -1; + return a - b; +} + export type SyncAllResult = QueueOutcome; // Queue a sync for every eligible channel. Each sync decides for itself whether @@ -481,19 +494,42 @@ export async function syncAllChannelsAction( const channels = await listChannelConfigs(paths); const bySlug = new Map(channels.map((c) => [c.slug, c.config])); const active = activeSyncSlugs(); - const outcome = await queueForSlugs( - channels.map((c) => c.slug), - { - skip: (slug) => { - const config = bySlug.get(slug); - if (!config?.url) return "no url"; - if (config.excludeFromSync) return "excluded from sync all"; - if (active.has(slug)) return "already running"; - return null; - }, - run: (slug) => syncAction(slug, undefined, opts?.fullSweep), - }, + // 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. + const priority = getSettings().channelPriority; + const slugs = channels.map((c) => c.slug); + const focus = new Set( + resolveFocusSlugs(priority, siteChannelIndex(paths), slugs), + ); + // ORDER: focus, then tier, then rank, then slug. `queueForSlugs` runs the + // list in order and each sync takes a slot on the platform queue, so on a + // 68-channel pool the order IS the priority — the focus channels' syncs are + // the ones that land first. + const order = [...slugs].sort( + (a, b) => + tierOrder(focus.has(a) ? "focus" : effectiveTier(priority, a, "sync")) - + tierOrder( + focus.has(b) ? "focus" : effectiveTier(priority, b, "sync"), + ) || + compareSyncRank(rankOf(priority, a), rankOf(priority, b)) || + a.localeCompare(b), ); + const outcome = await queueForSlugs(order, { + skip: (slug) => { + 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; + }, + run: (slug) => syncAction(slug, undefined, opts?.fullSweep), + }); // Record the sweep's freshness marker for the monitor widget's last-sync // readout. Read-modify-write right before the write keeps the clobber window // vs. a concurrent scheduler tick minimal (single-user editor — acceptable). diff --git a/editor/app/channels/components/ChannelBulkBar.tsx b/editor/app/channels/components/ChannelBulkBar.tsx @@ -23,8 +23,35 @@ import { focusChannelsAction, focusSiteAction, setChannelTierAction, + type ActionResult, } from "../actions"; +// A FAILED WRITE MUST SAY SO — the writer returns `{error}` before it +// revalidates, so a swallowed result reads as "the click did nothing". One +// runner per bar, one alert line, the shape SyncAllChannelsButton already uses. +function useBarAction(): { + pending: boolean; + error: string | null; + run: (action: () => Promise<ActionResult>, after?: () => void) => void; +} { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + return { + pending, + error, + run: (action, after) => + startTransition(async () => { + try { + const result = await action(); + setError(result?.error ?? null); + if (!result?.error) after?.(); + } catch (e) { + setError((e as Error).message); + } + }), + }; +} + export type FocusSite = { siteId: string; title: string }; const TIER_LABEL: Record<StoredChannelTier, string> = { @@ -43,7 +70,7 @@ export function ChannelFocusBar({ // pending numbers is S4's FocusBanner; this bar is only the control. focusLabel: string | null; }) { - const [pending, startTransition] = useTransition(); + const { pending, error, run } = useBarAction(); const [siteId, setSiteId] = useState(sites[0]?.siteId ?? ""); if (sites.length === 0 && !focusLabel) return null; @@ -74,11 +101,7 @@ export function ChannelFocusBar({ <button type="button" disabled={pending || !siteId} - onClick={() => - startTransition(async () => { - await focusSiteAction(siteId); - }) - } + onClick={() => run(() => focusSiteAction(siteId))} className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" > Focus site @@ -89,16 +112,21 @@ export function ChannelFocusBar({ <button type="button" disabled={pending} - onClick={() => - startTransition(async () => { - await endFocusAction(); - }) - } + onClick={() => run(() => endFocusAction())} className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50" > End focus </button> )} + {error && ( + <span + role="alert" + aria-label="focus error" + className="text-xs text-destructive" + > + {error} + </span> + )} </div> ); } @@ -110,7 +138,7 @@ export function ChannelBulkBar({ slugs: string[]; onClear: () => void; }) { - const [pending, startTransition] = useTransition(); + const { pending, error, run } = useBarAction(); const [tier, setTier] = useState<StoredChannelTier>("normal"); if (slugs.length === 0) return null; @@ -140,12 +168,7 @@ export function ChannelBulkBar({ <button type="button" disabled={pending} - onClick={() => - startTransition(async () => { - await setChannelTierAction(slugs, tier); - onClear(); - }) - } + onClick={() => run(() => setChannelTierAction(slugs, tier), onClear)} className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" > Apply tier @@ -153,12 +176,7 @@ export function ChannelBulkBar({ <button type="button" disabled={pending} - onClick={() => - startTransition(async () => { - await focusChannelsAction(slugs); - onClear(); - }) - } + onClick={() => run(() => focusChannelsAction(slugs), onClear)} className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-muted disabled:opacity-50" > Focus these @@ -170,6 +188,15 @@ export function ChannelBulkBar({ > Clear </button> + {error && ( + <span + role="alert" + aria-label="priority bulk error" + className="text-xs text-destructive" + > + {error} + </span> + )} </div> ); } diff --git a/editor/app/channels/components/ChannelTierSelect.tsx b/editor/app/channels/components/ChannelTierSelect.tsx @@ -25,7 +25,7 @@ // or the page's auto-refresh can never leave this select disagreeing with // settings.json, which a `useState` seeded from props would. -import { useOptimistic, useTransition } from "react"; +import { useOptimistic, useState, useTransition } from "react"; import { PRIORITY_OPERATIONS, STORED_CHANNEL_TIERS, @@ -36,6 +36,7 @@ import { applyChannelPriorityPresetAction, setChannelOperationTierAction, setChannelTierAction, + type ActionResult, } from "../actions"; export type ChannelTierSelectProps = { @@ -82,13 +83,24 @@ export default function ChannelTierSelect({ }: ChannelTierSelectProps): React.ReactNode { const [pending, startTransition] = useTransition(); const [view, setView] = useOptimistic<View>({ tier, overrides }); + // A FAILED WRITE MUST SAY SO. The writer returns `{error}` before it + // revalidates, so on a failure the optimistic value silently snaps back to + // the server's — which reads as "the click did nothing" rather than as an + // error. Same shape as SyncAllChannelsButton's: role="alert", a labelled + // span, text-destructive. + const [error, setError] = useState<string | null>(null); const pins = PRIORITY_OPERATIONS.filter((op) => view.overrides[op]); const busy = disabled || pending; - function run(next: View, action: () => Promise<unknown>) { + function run(next: View, action: () => Promise<ActionResult>) { startTransition(async () => { setView(next); - await action(); + try { + const result = await action(); + setError(result?.error ?? null); + } catch (e) { + setError((e as Error).message); + } }); } @@ -132,8 +144,18 @@ export default function ChannelTierSelect({ </span> )} </div> + {error && ( + <span + role="alert" + aria-label={`priority error for ${slug}`} + className="text-[11px] text-destructive" + > + {error} + </span> + )} {heldReason && ( <span + role="note" className="text-[11px] text-warning" aria-label={`held reason for ${slug}`} > diff --git a/editor/e2e/channel-priority.spec.ts b/editor/e2e/channel-priority.spec.ts @@ -145,6 +145,30 @@ test("the Sync only preset is paused everywhere with sync pinned back", async ({ } }); +// MOVED HERE FROM channel-sync-toggle.spec.ts, which this slice deletes with the +// control it drove. The assertion is the same one — a pool sweep skips the +// excluded channel and names it in the tooltip — restated against the tier the +// flag became. `syncAllChannelsAction` asks the document for the `sync` +// operation, so the manual sweep and the group Sync buttons agree. +test("Sync all skips a channel paused for sync and says which", async ({ + page, +}) => { + await resetData("two-slow-channels"); + await page.goto(ALL); + + await page.getByLabel("tier for slow-a", { exact: true }).selectOption("paused"); + await expect + .poll(async () => (await settings()).channelPriority?.channels["slow-a"]) + .toEqual({ tier: "paused" }); + + await page.getByRole("button", { name: "sync every channel" }).click(); + const result = page.getByLabel("sync all result"); + await expect(result).toContainText(/Queued 1 . skipped 1/, { + timeout: 10_000, + }); + await expect(result).toHaveAttribute("title", /slow-a: paused for sync/); +}); + test("Focus site holds the rest, and End focus releases them", async ({ page, }) => {