commit 7cfcab884b11e6c0696d4f03d1caaa3254914a36
parent dec95a926b9ed146db505fd22a41b751b20df572
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 26 Jun 2026 15:51:30 -0400
Phase 7: reorder / promote a queued job from Active Jobs
Granular manual queue control (priority feature #1): a QUEUED job's row now
shows Promote / ↑ / ↓ buttons that move it within its queue. Only actionable
moves render — the first-queued job offers no up/promote, the last no down.
- registry.reorder(id, dir) + registry.promote(id) delegate to the scheduler
(which owns ordering; running jobs are never displaced).
- reorderJobAction / promoteJobAction server actions wrap them + revalidate.
- buildActiveJobs computes per-job canMoveUp/canMoveDown from queue positions
(additive optional fields; the running head is position 0, queued are 1..n).
- ReorderJobButtons (new) mirrors the PolicyTreeEditor move buttons; wired into
RunningJobsList for queued rows.
The Active Jobs list is sorted by recency, not queue position, so the e2e
asserts the EFFECT: with 3 slow syncs on one queue, promoting the last queued
job then cancelling the running head makes the promoted job run next — ahead of
the FIFO-first one. Typecheck (common+editor) clean; jobs-reorder + active-order
+ jobs-channel e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 242 insertions(+), 0 deletions(-)
diff --git a/common/jobs/registry.ts b/common/jobs/registry.ts
@@ -310,6 +310,18 @@ class JobRegistry {
positionInQueue(id: string): number {
return getScheduler().positionInQueue(id);
}
+
+ // Granular manual control: move a QUEUED job one slot toward (-1) or away from
+ // (+1) the head, or to the front of the queued section. Never reorders a
+ // running job. Returns false if the move isn't possible. Delegates to the
+ // scheduler, which owns ordering.
+ reorder(id: string, dir: -1 | 1): boolean {
+ return getScheduler().reorder(id, dir);
+ }
+
+ promote(id: string): boolean {
+ return getScheduler().promote(id);
+ }
}
declare global {
diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts
@@ -36,6 +36,25 @@ export async function drainAllAction(): Promise<{ count: number }> {
return { count };
}
+// Granular control: nudge a QUEUED job up (-1) or down (+1) within its queue.
+// Only queued jobs move; the running head is never displaced.
+export async function reorderJobAction(
+ id: string,
+ dir: -1 | 1,
+): Promise<{ ok: boolean }> {
+ const ok = getRegistry().reorder(id, dir);
+ revalidatePath("/jobs");
+ return { ok };
+}
+
+// Granular control: jump a QUEUED job to the front of its queue (it runs next
+// when the current job releases the queue).
+export async function promoteJobAction(id: string): Promise<{ ok: boolean }> {
+ const ok = getRegistry().promote(id);
+ revalidatePath("/jobs");
+ return { ok };
+}
+
export async function clearArchivedAction(): Promise<{ deleted: number }> {
const deleted = await clearArchivedLogs(getPaths());
revalidatePath("/jobs");
diff --git a/editor/app/jobs/active/buildActiveJobs.ts b/editor/app/jobs/active/buildActiveJobs.ts
@@ -115,6 +115,19 @@ export async function buildActiveJobsPayload(): Promise<ActiveJobsPayload> {
const workerNames = new Map<string, string>();
for (const w of getWorkerPool().summary()) workerNames.set(w.id, w.name);
+ // Queue positions, to derive each queued job's reorder bounds. position 0 is
+ // the running head; queued jobs occupy 1..max. A parallel ("") job has
+ // position -1 and is never reorderable.
+ const positions = new Map<string, number>();
+ const queueMaxPos = new Map<string, number>();
+ for (const j of activeRecords) {
+ const pos = registry.positionInQueue(j.id);
+ positions.set(j.id, pos);
+ if (pos >= 0) {
+ queueMaxPos.set(j.queueKey, Math.max(queueMaxPos.get(j.queueKey) ?? 0, pos));
+ }
+ }
+
const now = Date.now();
const jobs: RunningJobsListItem[] = activeRecords.map((j) => ({
id: j.id,
@@ -145,6 +158,14 @@ export async function buildActiveJobsPayload(): Promise<ActiveJobsPayload> {
j.status === "running" && isDrainableKind(j.kind) && j.draining !== true,
bookmarkable: Boolean(j.spec),
background: j.background === true,
+ // A queued job can move up/promote if it isn't the first queued (position >
+ // 1, since the running head is at 0), and down if it isn't the last in its
+ // queue. Running and parallel ("") jobs are never reorderable.
+ canMoveUp: j.status === "queued" && (positions.get(j.id) ?? -1) > 1,
+ canMoveDown:
+ j.status === "queued" &&
+ (positions.get(j.id) ?? -1) >= 1 &&
+ (positions.get(j.id) ?? -1) < (queueMaxPos.get(j.queueKey) ?? 0),
}));
const channels = channelSlugs.map((slug) => ({
diff --git a/editor/app/jobs/components/ReorderJobButtons.tsx b/editor/app/jobs/components/ReorderJobButtons.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { promoteJobAction, reorderJobAction } from "../actions";
+
+type Props = {
+ jobId: string;
+ // Whether the job can move toward / away from the head of its queue. The
+ // running head is never displaced, so the first queued job can't move up.
+ canMoveUp?: boolean;
+ canMoveDown?: boolean;
+};
+
+// Granular manual control for a QUEUED job: nudge it up/down one slot in its
+// queue, or promote it to the front (it runs next when the queue frees). Mirrors
+// the move buttons in the auto-queue PolicyTreeEditor.
+export function ReorderJobButtons({ jobId, canMoveUp, canMoveDown }: Props) {
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const run = async (fn: () => Promise<unknown>) => {
+ setBusy(true);
+ try {
+ await fn();
+ router.refresh();
+ } finally {
+ setBusy(false);
+ }
+ };
+ const btn =
+ "px-1.5 py-1 rounded border border-zinc-300 dark:border-zinc-700 text-xs font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-40";
+ // Only render an action that's actually possible: a first-queued job can't move
+ // up or promote; a last-queued job can't move down.
+ return (
+ <div className="flex items-center gap-1">
+ {canMoveUp && (
+ <button
+ type="button"
+ disabled={busy}
+ onClick={() => run(() => promoteJobAction(jobId))}
+ aria-label={`promote job ${jobId} to front of queue`}
+ title="Run next (move to front of queue)"
+ className={btn}
+ >
+ Promote
+ </button>
+ )}
+ {canMoveUp && (
+ <button
+ type="button"
+ disabled={busy}
+ onClick={() => run(() => reorderJobAction(jobId, -1))}
+ aria-label={`move job ${jobId} up`}
+ title="Move up one slot"
+ className={btn}
+ >
+ ↑
+ </button>
+ )}
+ {canMoveDown && (
+ <button
+ type="button"
+ disabled={busy}
+ onClick={() => run(() => reorderJobAction(jobId, 1))}
+ aria-label={`move job ${jobId} down`}
+ title="Move down one slot"
+ className={btn}
+ >
+ ↓
+ </button>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx
@@ -8,6 +8,7 @@ import { jobKindLabel } from "../jobKindLabels";
import { DrainJobButton } from "./DrainJobButton";
import { CancelJobButton } from "./CancelJobButton";
import { BookmarkJobButton } from "./BookmarkJobButton";
+import { ReorderJobButtons } from "./ReorderJobButtons";
export type RunningJobsTask = {
id: string;
@@ -54,6 +55,11 @@ export type RunningJobsListItem = {
// the same platform queue. Shown as an "auto" badge, and — when queued — as a
// hint that a foreground job (a clicked Sync) is being let through first.
background?: boolean;
+ // Reorder affordances for a QUEUED job: whether it can move toward (up/promote)
+ // or away from (down) the head of its queue. Both false for running jobs and
+ // for parallel ("") jobs that aren't serialized.
+ canMoveUp?: boolean;
+ canMoveDown?: boolean;
};
type Props = {
@@ -149,6 +155,13 @@ function JobRow({
)}
<div className="ml-auto flex items-center gap-2">
{job.bookmarkable && <BookmarkJobButton jobId={job.id} />}
+ {job.status === "queued" && (job.canMoveUp || job.canMoveDown) && (
+ <ReorderJobButtons
+ jobId={job.id}
+ canMoveUp={job.canMoveUp}
+ canMoveDown={job.canMoveDown}
+ />
+ )}
{job.status === "running" && (job.drainable || job.draining) && (
<DrainJobButton jobId={job.id} draining={job.draining} />
)}
diff --git a/editor/e2e/jobs-reorder.spec.ts b/editor/e2e/jobs-reorder.spec.ts
@@ -0,0 +1,103 @@
+// Granular queue control: the ↑/↓/Promote buttons on a QUEUED job's row in
+// Active Jobs reorder it within its queue. The visual list is sorted by recency,
+// not queue position, so this asserts the EFFECT: a promoted job runs next when
+// the head is cancelled, ahead of the job that was FIFO-first.
+
+import { mkdir, writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { resetData, resolvePath } from "./helpers";
+import { baseUrl } from "./baseUrl";
+
+async function invalidateCache() {
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+}
+
+// A channel whose Sync runs slowly (fake-ytdlp honors --test-slow), so it stays
+// "running"/"queued" long enough to reorder.
+async function makeSlowChannel(slug: string, name: string) {
+ const root = resolvePath(`test-transcripts/channels/${slug}`);
+ await mkdir(root, { recursive: true });
+ await writeFile(
+ `${root}/config.json`,
+ JSON.stringify({
+ handling: "youtube",
+ name,
+ url: `https://www.youtube.com/@${slug}/videos`,
+ ytdlpExtraArgs: ["--test-slow"],
+ }),
+ );
+}
+
+async function setQueueViaCustom(
+ page: import("@playwright/test").Page,
+ actionLabel: string,
+ value: string,
+) {
+ await page
+ .getByLabel(`queue for ${actionLabel}`, { exact: true })
+ .selectOption({ label: "Custom…" });
+ await page
+ .getByLabel(`custom queue for ${actionLabel}`, { exact: true })
+ .fill(value);
+ await page
+ .getByRole("button", { name: `save custom queue for ${actionLabel}` })
+ .click();
+}
+
+async function syncToQueue(
+ page: import("@playwright/test").Page,
+ slug: string,
+ queue: string,
+) {
+ await page.goto(`/channels/${slug}`);
+ await setQueueViaCustom(page, "Sync", queue);
+ await page.getByRole("button", { name: "Sync" }).click();
+}
+
+test("Promote makes a queued job run next, ahead of the FIFO-first one", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await resetData(null);
+ await makeSlowChannel("reorder-a", "Reorder A");
+ await makeSlowChannel("reorder-b", "Reorder B");
+ await makeSlowChannel("reorder-c", "Reorder C");
+ await invalidateCache();
+
+ // A runs; B (FIFO-first) then C queue behind it on the shared queue.
+ await syncToQueue(page, "reorder-a", "qReorder");
+ await expect(page.getByLabel("Sync output")).toContainText("test-slow", {
+ timeout: 15_000,
+ });
+ await syncToQueue(page, "reorder-b", "qReorder");
+ await expect(page.getByLabel("Sync queue status")).toBeVisible({
+ timeout: 10_000,
+ });
+ await syncToQueue(page, "reorder-c", "qReorder");
+ await expect(page.getByLabel("Sync queue status")).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await page.goto("/jobs/active");
+ const sectionA = page.locator("section[aria-label='Active jobs for Reorder A']");
+ const sectionB = page.locator("section[aria-label='Active jobs for Reorder B']");
+ const sectionC = page.locator("section[aria-label='Active jobs for Reorder C']");
+ await expect(sectionA.getByText("running", { exact: true })).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(sectionB.getByText("queued", { exact: true })).toBeVisible();
+ await expect(sectionC.getByText("queued", { exact: true })).toBeVisible();
+
+ // C is last in the queue, so its row offers Promote; B (first queued) does not.
+ await expect(
+ sectionB.getByRole("button", { name: /^promote job/ }),
+ ).toHaveCount(0);
+ await sectionC.getByRole("button", { name: /^promote job/ }).click();
+
+ // Cancel the running A; the PROMOTED C runs next, not the FIFO-first B.
+ await sectionA.getByRole("button", { name: /^Cancel$/ }).click();
+ await expect(sectionC.getByText("running", { exact: true })).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(sectionB.getByText("queued", { exact: true })).toBeVisible();
+});