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:
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,
}) => {