add pipeline job management with /jobs pages and per-step output viewer

- Pipeline jobs now persist to DB with progress tracking and cost accumulation
- Jobs survive WebSocket disconnects with in-memory event buffer replay
- New /jobs list page with search, status badges, and cost display
- New /jobs/:id detail page with live WebSocket attachment and REST fallback
- Two-column layout using WorkspaceLayout for resizable steps/output panels
- Streaming messages tagged with stepIndex/iterationLabel for per-step output grouping
- TaskRunnerModal links to job detail page once job is created
- Dock entry added for Jobs page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 08:48:50 +00:00
co-authored by Claude Opus 4.6
parent 22c60d4a57
commit 5e38343f44
22 changed files with 4506 additions and 252 deletions
@@ -0,0 +1,20 @@
CREATE TABLE "pipeline_jobs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"task_dir_name" text NOT NULL,
"task_name" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"inputs" jsonb DEFAULT '{}'::jsonb NOT NULL,
"cwd" text,
"config" jsonb NOT NULL,
"progress" jsonb,
"total_cost" jsonb,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "pipeline_jobs" ADD CONSTRAINT "pipeline_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_pipeline_jobs_user_created" ON "pipeline_jobs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_pipeline_jobs_status" ON "pipeline_jobs" USING btree ("status");
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,13 @@
"when": 1772985087180,
"tag": "0002_cute_doorman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1773040399200,
"tag": "0003_perpetual_james_howlett",
"breakpoints": true
}
]
}
+8
View File
@@ -81,5 +81,13 @@ export {
deleteTask,
} from './queries/tasks';
export {
createPipelineJob,
getPipelineJob,
updatePipelineJob,
getPipelineJobsForUser,
markInterruptedJobs,
} from './queries/pipeline-jobs';
export { db } from './db';
export * as schema from './schema';
@@ -0,0 +1,36 @@
import { eq, and, inArray, desc } from 'drizzle-orm';
import { db } from '../db';
import { pipelineJobs } from '../schema/pipeline-jobs';
import type { PipelineJobInsert } from '../types';
export async function createPipelineJob(data: PipelineJobInsert) {
const rows = await db.insert(pipelineJobs).values(data).returning();
return rows[0]!;
}
export async function getPipelineJob(id: string) {
const rows = await db.select().from(pipelineJobs).where(eq(pipelineJobs.id, id)).limit(1);
return rows[0] ?? null;
}
export async function updatePipelineJob(id: string, data: Partial<PipelineJobInsert>) {
await db.update(pipelineJobs).set(data).where(eq(pipelineJobs.id, id));
}
export async function getPipelineJobsForUser(userId: number, limit = 50) {
return db
.select()
.from(pipelineJobs)
.where(eq(pipelineJobs.userId, userId))
.orderBy(desc(pipelineJobs.createdAt))
.limit(limit);
}
export async function markInterruptedJobs() {
const result = await db
.update(pipelineJobs)
.set({ status: 'interrupted', completedAt: new Date() })
.where(inArray(pipelineJobs.status, ['pending', 'running']))
.returning({ id: pipelineJobs.id });
return result.length;
}
@@ -6,3 +6,4 @@ export * from './agent-items';
export * from './operations';
export * from './server';
export * from './email';
export * from './pipeline-jobs';
@@ -0,0 +1,22 @@
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(),
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'),
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),
]);
+5
View File
@@ -103,3 +103,8 @@ export type ServerConfigInsert = typeof Schema.serverConfig.$inferInsert;
export type ServerIntegrationSelect = typeof Schema.serverIntegrations.$inferSelect;
export type ServerIntegrationInsert = typeof Schema.serverIntegrations.$inferInsert;
// ── Pipeline Jobs ──
export type PipelineJobSelect = typeof Schema.pipelineJobs.$inferSelect;
export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert;