jobs: video/audio download as a two-phase job (backend)

Turns the downloader into a server-side job on the existing jobs spine (Postgres
persistence, live WS viewers + replay, abort, /jobs UI) — but with its own
executor and its own lane, since it's deterministic scripting, not an agent, and
a multi-hour playlist mustn't block agentic jobs.

- reclip-client.ts (new, shared): reclipInfo / reclipPlaylist / reclipDownloadOne
  (single download → streams the file to a dir, abort-aware). Extracted so both
  the file-browser endpoints and the job executor use one client.
- execute-download.ts (new): the two-phase executor —
  phase 1 metadata (expand playlist, fetch each info, keep survivors, skip
  errors), phase 2 download (each survivor in the chosen format; skip download
  errors). Emits a compact `download:progress` snapshot (counters, not per-item
  events — playlists are thousands of items). Throws on abort / fatal.
- job manager: `download` mode dispatch → executeDownload; persists
  download:progress; adds execution LANES (download vs default) so the two run
  independently and each serializes on its own; promoteNext fills both lanes.
- POST /api/tasks/jobs/download { url, format, dir, root?, label? } — enqueues a
  download job (own lane, no capability task needed; traversal-guarded target).
- schema: `download` added to the mode enum (drizzle text-enum — no DB migration);
  getPendingJobs() query for lane filling.

Verified the executor with a mocked ReClip client: two-phase filtering, skip-on-
error counts, and abort-throws all correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 18:09:37 +00:00
co-authored by Claude Opus 4.8
parent 612fd18c41
commit 4e78986e39
8 changed files with 387 additions and 69 deletions
+1
View File
@@ -70,6 +70,7 @@ export {
updatePipelineJob,
getPipelineJobsForUser,
getOldestPendingJob,
getPendingJobs,
countPendingJobs,
deletePipelineJob,
deleteTerminalJobsForUser,
@@ -62,6 +62,11 @@ export async function getOldestPendingJob() {
return rows[0] ?? null;
}
// All queued jobs, oldest first — used to fill each execution lane (download vs agentic) independently.
export async function getPendingJobs() {
return db.select().from(pipelineJobs).where(eq(pipelineJobs.status, 'pending')).orderBy(asc(pipelineJobs.createdAt));
}
// On restart, only RUNNING jobs are orphaned (their process died) → interrupted. PENDING jobs are the
// queue backlog and must survive to be promoted after startup.
export async function markInterruptedJobs() {
@@ -1,26 +1,37 @@
import { pgTable, text, integer, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const pipelineJobs = pgTable('pipeline_jobs', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskDirName: text('task_dir_name').notNull(),
taskName: text('task_name').notNull(),
// Job kind — pipeline (multi-step agentic), script (single bash/py/ts task), later agentic.
mode: text('mode', { enum: ['pipeline', 'script', 'agentic'] }).notNull().default('pipeline'),
status: text('status', { enum: ['pending', 'running', 'completed', 'failed', 'stopped', 'interrupted'] }).notNull().default('pending'),
inputs: jsonb('inputs').notNull().default({}),
cwd: text('cwd'),
config: jsonb('config').notNull(),
progress: jsonb('progress'),
totalCost: jsonb('total_cost'),
error: text('error'),
// Script jobs: the process exit code (null until finished / for non-script jobs).
exitCode: integer('exit_code'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => [
index('idx_pipeline_jobs_user_created').on(table.userId, table.createdAt),
index('idx_pipeline_jobs_status').on(table.status),
]);
export const pipelineJobs = pgTable(
'pipeline_jobs',
{
id: text('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
taskDirName: text('task_dir_name').notNull(),
taskName: text('task_name').notNull(),
// Job kind — pipeline (multi-step agentic), script (single bash/py/ts task), download (video/audio via
// ReClip — its own lane so a long download doesn't block agentic jobs), later agentic.
mode: text('mode', { enum: ['pipeline', 'script', 'agentic', 'download'] })
.notNull()
.default('pipeline'),
status: text('status', { enum: ['pending', 'running', 'completed', 'failed', 'stopped', 'interrupted'] })
.notNull()
.default('pending'),
inputs: jsonb('inputs').notNull().default({}),
cwd: text('cwd'),
config: jsonb('config').notNull(),
progress: jsonb('progress'),
totalCost: jsonb('total_cost'),
error: text('error'),
// Script jobs: the process exit code (null until finished / for non-script jobs).
exitCode: integer('exit_code'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(table) => [
index('idx_pipeline_jobs_user_created').on(table.userId, table.createdAt),
index('idx_pipeline_jobs_status').on(table.status),
],
);