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:
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user