Archilyzer · Source

archilyzer

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

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:
Mcommon/jobs/registry.ts | 12++++++++++++
Meditor/app/jobs/actions.ts | 19+++++++++++++++++++
Meditor/app/jobs/active/buildActiveJobs.ts | 21+++++++++++++++++++++
Aeditor/app/jobs/components/ReorderJobButtons.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/jobs/components/RunningJobsList.tsx | 13+++++++++++++
Aeditor/e2e/jobs-reorder.spec.ts | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(); +});