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