Archilyzer · Source

archilyzer

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

commit 2695aa4949d217d0eb20d21e471b37a31d3ddf95
parent 59259161cf146874e9cfc45e4f04cda7629d52d7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 27 Apr 2026 23:35:12 -0400

feat(editor): show originating channel on jobs and auto-refresh the jobs list

Jobs created from a channel page now record the channel slug. The jobs list
gains a Channel column linking back to /channels/<slug>, the detail page
shows the same link near the status header, and the list auto-refreshes
every 2.5s while at least one job is still running so download progress
updates without a manual reload. Archived (post-restart) jobs render an
em-dash since their channel context is lost when the registry resets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/jobs/listJobs.ts | 3+++
Mcommon/jobs/registry.ts | 1+
Mcommon/jobs/streamCommand.ts | 17+++++++++++++++--
Meditor/app/channels/[slug]/pipelineActions.ts | 1+
Meditor/app/channels/[slug]/whisperActions.ts | 2++
Meditor/app/jobs/[id]/page.tsx | 11+++++++++++
Aeditor/app/jobs/_components/JobsAutoRefresh.tsx | 18++++++++++++++++++
Meditor/app/jobs/page.tsx | 19+++++++++++++++++++
Aeditor/cypress/e2e/jobs-channel.cy.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 119 insertions(+), 2 deletions(-)

diff --git a/common/jobs/listJobs.ts b/common/jobs/listJobs.ts @@ -6,6 +6,7 @@ import { getRegistry, type JobRecord, type JobStatus } from "./registry"; export type JobListEntry = { id: string; kind?: string; + channelSlug?: string; status: JobStatus | "archived"; startedAt: number; endedAt?: number; @@ -51,6 +52,7 @@ export async function listAllJobs(paths: Paths): Promise<JobListEntry[]> { out.push({ id, kind: live_.kind, + channelSlug: live_.channelSlug, status: live_.status, startedAt: live_.startedAt, endedAt: live_.endedAt, @@ -79,6 +81,7 @@ export async function listAllJobs(paths: Paths): Promise<JobListEntry[]> { out.push({ id: r.id, kind: r.kind, + channelSlug: r.channelSlug, status: r.status, startedAt: r.startedAt, endedAt: r.endedAt, diff --git a/common/jobs/registry.ts b/common/jobs/registry.ts @@ -6,6 +6,7 @@ export type JobRecord = { id: string; kind: string; mutexKey?: string; + channelSlug?: string; status: JobStatus; startedAt: number; endedAt?: number; diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts @@ -14,6 +14,7 @@ type CommonOpts = { mutexKey: string; paths: Paths; busyMessage?: string; + channelSlug?: string; }; export type RunManagedCommandOpts = CommonOpts & { @@ -38,6 +39,7 @@ function makeJob( kind: string, mutexKey: string, paths: Paths, + channelSlug?: string, ): { id: string; logPath: string; record: JobRecord } { const id = newJobId(); const logPath = path.join(paths.jobsDir, `${id}.log`); @@ -45,6 +47,7 @@ function makeJob( id, kind, mutexKey, + channelSlug, status: "running", startedAt: Date.now(), logPath, @@ -64,7 +67,12 @@ export async function runManagedCommand( }; } await ensureJobsDir(opts.paths); - const { id, logPath, record } = makeJob(opts.kind, opts.mutexKey, opts.paths); + const { id, logPath, record } = makeJob( + opts.kind, + opts.mutexKey, + opts.paths, + opts.channelSlug, + ); const child = execa(opts.command, opts.args, { cwd: opts.cwd, @@ -147,7 +155,12 @@ export async function runManagedFunction( }; } await ensureJobsDir(opts.paths); - const { id, logPath, record } = makeJob(opts.kind, opts.mutexKey, opts.paths); + const { id, logPath, record } = makeJob( + opts.kind, + opts.mutexKey, + opts.paths, + opts.channelSlug, + ); const abort = new AbortController(); record.abortController = abort; diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -26,6 +26,7 @@ async function runPipelineAction( kind, mutexKey: `channel:${slug}`, paths, + channelSlug: slug, busyMessage: `Channel "${slug}" already has an operation running`, fn: async (onLog, signal) => { await runYtdlp({ diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts @@ -17,6 +17,7 @@ export async function transcribeMissingAction( kind: "whisper-all", mutexKey: `channel:${slug}`, paths, + channelSlug: slug, busyMessage: `Channel "${slug}" already has an operation running`, fn: async (onLog, signal) => { const result = await runWhisperBatch({ @@ -42,6 +43,7 @@ export async function retryFailuresAction( kind: "whisper-retry", mutexKey: `channel:${slug}`, paths, + channelSlug: slug, busyMessage: `Channel "${slug}" already has an operation running`, fn: async (onLog, signal) => { const result = await runWhisperBatch({ diff --git a/editor/app/jobs/[id]/page.tsx b/editor/app/jobs/[id]/page.tsx @@ -29,6 +29,17 @@ export default async function JobDetailPage({ <span className="font-mono">{id}</span> </div> <h1 className="text-2xl font-semibold">{job.kind ?? "job"}</h1> + {job.channelSlug && ( + <p className="text-sm" data-testid="job-channel"> + Channel:{" "} + <Link + href={`/channels/${job.channelSlug}`} + className="underline font-mono hover:text-zinc-900 dark:hover:text-zinc-100" + > + {job.channelSlug} + </Link> + </p> + )} <dl className="text-sm grid grid-cols-2 sm:grid-cols-4 gap-3"> <Cell label="Status" value={job.status} /> <Cell label="Started" value={new Date(job.startedAt).toLocaleString()} /> diff --git a/editor/app/jobs/_components/JobsAutoRefresh.tsx b/editor/app/jobs/_components/JobsAutoRefresh.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +type Props = { + hasNonTerminal: boolean; +}; + +export function JobsAutoRefresh({ hasNonTerminal }: Props) { + const router = useRouter(); + useEffect(() => { + if (!hasNonTerminal) return; + const id = setInterval(() => router.refresh(), 2_500); + return () => clearInterval(id); + }, [hasNonTerminal, router]); + return null; +} diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx @@ -3,6 +3,7 @@ import { listAllJobs } from "yt-dlp-transcript-common/jobs/listJobs"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { ClearArchivedButton } from "./_components/ClearArchivedButton"; import { CancelJobButton } from "./_components/CancelJobButton"; +import { JobsAutoRefresh } from "./_components/JobsAutoRefresh"; export const dynamic = "force-dynamic"; @@ -31,8 +32,10 @@ function statusColor(status: string): string { export default async function JobsPage() { const jobs = await listAllJobs(getPaths()); + const hasNonTerminal = jobs.some((j) => j.status === "running"); return ( <div className="flex flex-col gap-4"> + <JobsAutoRefresh hasNonTerminal={hasNonTerminal} /> <div className="flex items-center justify-between"> <h1 className="text-2xl font-semibold">Jobs</h1> <ClearArchivedButton /> @@ -53,6 +56,7 @@ export default async function JobsPage() { <tr> <th className="text-left font-medium px-3 py-2">ID</th> <th className="text-left font-medium px-3 py-2">Kind</th> + <th className="text-left font-medium px-3 py-2">Channel</th> <th className="text-left font-medium px-3 py-2">Status</th> <th className="text-left font-medium px-3 py-2">Started</th> <th className="text-left font-medium px-3 py-2">Duration</th> @@ -80,6 +84,21 @@ export default async function JobsPage() { </Link> </td> <td className="px-3 py-2">{j.kind ?? "—"}</td> + <td + className="px-3 py-2 font-mono text-xs" + data-testid={`job-row-channel-${j.id}`} + > + {j.channelSlug ? ( + <Link + href={`/channels/${j.channelSlug}`} + className="underline hover:text-zinc-900 dark:hover:text-zinc-100" + > + {j.channelSlug} + </Link> + ) : ( + "—" + )} + </td> <td className="px-3 py-2"> <span className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`} diff --git a/editor/cypress/e2e/jobs-channel.cy.ts b/editor/cypress/e2e/jobs-channel.cy.ts @@ -0,0 +1,49 @@ +describe("Job→channel link and jobs list auto-refresh", () => { + it("shows the originating channel as a link on the jobs list and detail", () => { + cy.resetData("test-pipeline"); + cy.visit("/channels/test-pipeline"); + cy.findByTestId("pipeline-store").find("button").click(); + cy.findByTestId("pipeline-store-log", { timeout: 20_000 }).should( + "contain.text", + "Wrote 5 URLs", + ); + + cy.visit("/jobs"); + cy.get('[data-testid^="job-row-"]') + .first() + .invoke("attr", "data-testid") + .then((testId) => { + const id = (testId as string).replace(/^job-row-/, ""); + cy.findByTestId(`job-row-channel-${id}`) + .find("a") + .should("have.attr", "href", "/channels/test-pipeline") + .and("contain.text", "test-pipeline"); + cy.visit(`/jobs/${id}`); + cy.findByTestId("job-channel") + .find("a") + .should("have.attr", "href", "/channels/test-pipeline"); + }); + }); + + it("auto-refreshes the jobs list when a job is running", () => { + cy.resetData("slow-pipeline-channel"); + cy.visit("/channels/slow-channel"); + cy.findByTestId("pipeline-sync").find("button").click(); + cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should( + "contain.text", + "cypress-slow", + ); + + // Switch to the jobs list and don't manually reload. The status should + // start as "running" and flip to "cancelled" within ~5s after we cancel + // the underlying job. + cy.visit("/jobs"); + cy.get('[data-testid^="job-row-"]').first().contains(/^running$/i); + + cy.get('[data-testid^="cancel-"]').first().click(); + // Don't visit again — the auto-refresh should pick up the status change. + cy.get('[data-testid^="job-row-"]') + .first() + .contains(/^cancelled$/i, { timeout: 10_000 }); + }); +});