jobs: queue scheduler + REST job API (phase 1c + 2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -63,7 +63,12 @@ Favor power-user affordances over guardrails. See memory `sole-user-assume-compe
|
|||||||
abort poll, returns exitCode), `process-tree.ts` (shared killTree), `pipeline-job-manager` now
|
abort poll, returns exitCode), `process-tree.ts` (shared killTree), `pipeline-job-manager` now
|
||||||
dispatches by `mode` and finalizes script jobs by exit code. *Compiles; runtime-untested until
|
dispatches by `mode` and finalizes script jobs by exit code. *Compiles; runtime-untested until
|
||||||
a REST caller + restart exist.*
|
a REST caller + restart exist.*
|
||||||
- [ ] 1c scheduler / queue (FIFO pending → promote on finalize; promoteNext on startup)
|
- [x] 1c scheduler / queue — `enqueueJob(action)` (start now / queue behind running), `promoteNext()`
|
||||||
- [ ] 2 REST job API + WS consolidation
|
on finalize + startup, `getOldestPendingJob`, `markInterruptedJobs` now running-only (pending
|
||||||
|
queue survives restart). `startJob` kept as a `enqueueJob(...,'start')` wrapper.
|
||||||
|
- [x] 2 REST job API — `POST /jobs` (create script|pipeline, action start/queue), `GET /jobs` (+`?live=1`,
|
||||||
|
now returns mode/exitCode/isLive), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`.
|
||||||
|
Router mounted at `/jobs` and `/pipeline-jobs`. *Needs a restart to deploy; then curl/phone-testable.*
|
||||||
|
WS consolidation still pending (old `/api/tasks/run/ws` + `/api/tasks/pipeline/ws` still live).
|
||||||
- [ ] 3 /jobs/new page + JobDetail script branch + retire modal + header indicator
|
- [ ] 3 /jobs/new page + JobDetail script branch + retire modal + header indicator
|
||||||
- [ ] 4 push notifications
|
- [ ] 4 push notifications
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export {
|
|||||||
getPipelineJob,
|
getPipelineJob,
|
||||||
updatePipelineJob,
|
updatePipelineJob,
|
||||||
getPipelineJobsForUser,
|
getPipelineJobsForUser,
|
||||||
|
getOldestPendingJob,
|
||||||
markInterruptedJobs,
|
markInterruptedJobs,
|
||||||
} from './queries/pipeline-jobs';
|
} from './queries/pipeline-jobs';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, and, inArray, desc } from 'drizzle-orm';
|
import { eq, asc, desc } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { pipelineJobs } from '../schema/pipeline-jobs';
|
import { pipelineJobs } from '../schema/pipeline-jobs';
|
||||||
import type { PipelineJobInsert } from '../types';
|
import type { PipelineJobInsert } from '../types';
|
||||||
@@ -26,11 +26,24 @@ export async function getPipelineJobsForUser(userId: number, limit = 50) {
|
|||||||
.limit(limit);
|
.limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Oldest queued job across everything (single-user → global queue). Used to promote the next job.
|
||||||
|
export async function getOldestPendingJob() {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pipelineJobs)
|
||||||
|
.where(eq(pipelineJobs.status, 'pending'))
|
||||||
|
.orderBy(asc(pipelineJobs.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
export async function markInterruptedJobs() {
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(pipelineJobs)
|
.update(pipelineJobs)
|
||||||
.set({ status: 'interrupted', completedAt: new Date() })
|
.set({ status: 'interrupted', completedAt: new Date() })
|
||||||
.where(inArray(pipelineJobs.status, ['pending', 'running']))
|
.where(eq(pipelineJobs.status, 'running'))
|
||||||
.returning({ id: pipelineJobs.id });
|
.returning({ id: pipelineJobs.id });
|
||||||
return result.length;
|
return result.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
getPipelineJob,
|
getPipelineJob,
|
||||||
updatePipelineJob,
|
updatePipelineJob,
|
||||||
getPipelineJobsForUser,
|
getPipelineJobsForUser,
|
||||||
|
getOldestPendingJob,
|
||||||
markInterruptedJobs,
|
markInterruptedJobs,
|
||||||
|
getUserById,
|
||||||
} from 'officerdb';
|
} from 'officerdb';
|
||||||
|
import { toShellUsername } from '../../data-path';
|
||||||
import { executePipeline } from './pipeline-executor';
|
import { executePipeline } from './pipeline-executor';
|
||||||
import type { OutMessage } from './pipeline-executor';
|
import type { OutMessage } from './pipeline-executor';
|
||||||
import { executeScript, type ScriptEvent } from './execute-script';
|
import { executeScript, type ScriptEvent } from './execute-script';
|
||||||
@@ -79,24 +82,39 @@ type StartJobParams = {
|
|||||||
startAt?: number;
|
startAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function startJob(params: StartJobParams): Promise<string> {
|
export function runningCount(): number {
|
||||||
|
return liveJobs.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a job. action 'start' runs it now; 'queue' runs it only if nothing is running, else it stays
|
||||||
|
// 'pending' and gets promoted when the running job finishes. (Single user → one global queue.)
|
||||||
|
export async function enqueueJob(params: StartJobParams, action: 'start' | 'queue'): Promise<{ jobId: string; status: 'running' | 'pending' }> {
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
const mode: JobMode = params.mode ?? 'pipeline';
|
const mode: JobMode = params.mode ?? 'pipeline';
|
||||||
|
const run = action === 'start' || runningCount() === 0;
|
||||||
// Create DB record
|
|
||||||
await createPipelineJob({
|
await createPipelineJob({
|
||||||
id: jobId,
|
id: jobId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
taskDirName: params.taskDirName,
|
taskDirName: params.taskDirName,
|
||||||
taskName: params.taskName,
|
taskName: params.taskName,
|
||||||
mode,
|
mode,
|
||||||
status: 'running',
|
status: run ? 'running' : 'pending',
|
||||||
inputs: params.inputs,
|
inputs: params.inputs,
|
||||||
cwd: params.cwd ?? null,
|
cwd: params.cwd ?? null,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
startedAt: new Date(),
|
startedAt: run ? new Date() : null,
|
||||||
});
|
});
|
||||||
|
if (run) launch(jobId, mode, params);
|
||||||
|
return { jobId, status: run ? 'running' : 'pending' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back-compat: the pipeline WS caller expects startJob(params) -> jobId, running immediately.
|
||||||
|
export async function startJob(params: StartJobParams): Promise<string> {
|
||||||
|
return (await enqueueJob(params, 'start')).jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build live state + run the executor for a job whose DB row is already status 'running'.
|
||||||
|
function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||||
// Create live state
|
// Create live state
|
||||||
const job: LiveJob = {
|
const job: LiveJob = {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -191,6 +209,7 @@ export async function startJob(params: StartJobParams): Promise<string> {
|
|||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
|
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
|
||||||
liveJobs.delete(jobId);
|
liveJobs.delete(jobId);
|
||||||
|
void promoteNext();
|
||||||
}).catch(async (err) => {
|
}).catch(async (err) => {
|
||||||
clearInterval(flushInterval);
|
clearInterval(flushInterval);
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -204,9 +223,36 @@ export async function startJob(params: StartJobParams): Promise<string> {
|
|||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
}).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e));
|
}).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e));
|
||||||
liveJobs.delete(jobId);
|
liveJobs.delete(jobId);
|
||||||
|
void promoteNext();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return jobId;
|
// When a job finishes (and nothing else is running), promote the oldest queued job. Also called on
|
||||||
|
// startup to resume a backlog. Rebuilds the user context from userId since there's no request here.
|
||||||
|
async function promoteNext(): Promise<void> {
|
||||||
|
if (runningCount() > 0) return;
|
||||||
|
const next = await getOldestPendingJob();
|
||||||
|
if (!next) return;
|
||||||
|
const user = await getUserById(next.userId);
|
||||||
|
if (!user) {
|
||||||
|
await updatePipelineJob(next.id, { status: 'failed', error: 'user not found', completedAt: new Date() }).catch(() => {});
|
||||||
|
return promoteNext();
|
||||||
|
}
|
||||||
|
await updatePipelineJob(next.id, { status: 'running', startedAt: new Date() });
|
||||||
|
const nextMode = (next.mode as JobMode) ?? 'pipeline';
|
||||||
|
launch(next.id, nextMode, {
|
||||||
|
userId: next.userId,
|
||||||
|
email: user.email,
|
||||||
|
username: toShellUsername(user.username ?? '', user.email),
|
||||||
|
role: user.role ?? '',
|
||||||
|
mode: nextMode,
|
||||||
|
sandboxed: (user.role ?? '') !== 'Super Admin',
|
||||||
|
taskDirName: next.taskDirName,
|
||||||
|
taskName: next.taskName,
|
||||||
|
inputs: next.inputs as Record<string, string>,
|
||||||
|
cwd: next.cwd ?? undefined,
|
||||||
|
config: next.config,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachViewer(jobId: string, ws: ServerWebSocket<WSData>) {
|
export function attachViewer(jobId: string, ws: ServerWebSocket<WSData>) {
|
||||||
@@ -271,6 +317,8 @@ export async function cleanupOnStartup() {
|
|||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
console.log(`[pipeline-jobs] marked ${count} interrupted jobs on startup`);
|
console.log(`[pipeline-jobs] marked ${count} interrupted jobs on startup`);
|
||||||
}
|
}
|
||||||
|
// Resume any queued backlog that survived the restart.
|
||||||
|
await promoteNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract minimal progress info from events for DB storage. Script events fall through to `prev`. */
|
/** Extract minimal progress info from events for DB storage. Script events fall through to `prev`. */
|
||||||
|
|||||||
@@ -1,26 +1,106 @@
|
|||||||
|
import { stat, open } from 'node:fs/promises';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
|
import * as errors from '../../custom-errors';
|
||||||
import { getPipelineJobsForUser, getPipelineJob } from 'officerdb';
|
import { getPipelineJobsForUser, getPipelineJob } from 'officerdb';
|
||||||
|
import { getTaskByDirName } from './task-files';
|
||||||
|
import * as jobManager from './pipeline-job-manager';
|
||||||
|
import { jobLogPath } from './execute-script';
|
||||||
|
|
||||||
export const pipelineJobsRouter = createRouter();
|
export const pipelineJobsRouter = createRouter();
|
||||||
|
|
||||||
// GET /pipeline-jobs — list user's pipeline jobs
|
// GET / — list the user's jobs (optionally only the live ones).
|
||||||
pipelineJobsRouter.get('/', async (c) => {
|
pipelineJobsRouter.get('/', async (c) => {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const jobs = await getPipelineJobsForUser(user.id);
|
const liveOnly = c.req.query('live') === '1';
|
||||||
return c.json(jobs.map((j) => ({
|
const jobs = await jobManager.getJobsForUser(user.id);
|
||||||
id: j.id,
|
return c.json(
|
||||||
taskDirName: j.taskDirName,
|
jobs
|
||||||
taskName: j.taskName,
|
.filter((j) => (liveOnly ? j.isLive : true))
|
||||||
status: j.status,
|
.map((j) => ({
|
||||||
totalCost: j.totalCost,
|
id: j.id,
|
||||||
createdAt: j.createdAt,
|
mode: j.mode,
|
||||||
startedAt: j.startedAt,
|
taskDirName: j.taskDirName,
|
||||||
completedAt: j.completedAt,
|
taskName: j.taskName,
|
||||||
error: j.error,
|
status: j.status,
|
||||||
})));
|
isLive: j.isLive,
|
||||||
|
exitCode: j.exitCode,
|
||||||
|
totalCost: j.totalCost,
|
||||||
|
createdAt: j.createdAt,
|
||||||
|
startedAt: j.startedAt,
|
||||||
|
completedAt: j.completedAt,
|
||||||
|
error: j.error,
|
||||||
|
})),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /pipeline-jobs/:id — single job detail
|
// POST / — create a job (script or pipeline). action 'start' runs now; 'queue' waits behind a running
|
||||||
|
// job. Returns { jobId, status }. This is the REST creation path the phone / unattended runs use.
|
||||||
|
pipelineJobsRouter.post('/', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json<{ taskDirName: string; inputs?: Record<string, string>; cwd?: string; action?: 'start' | 'queue' }>();
|
||||||
|
if (!body.taskDirName) throw errors.BAD_REQUEST('taskDirName is required');
|
||||||
|
|
||||||
|
const task = await getTaskByDirName(body.taskDirName);
|
||||||
|
if (!task) throw errors.NOT_FOUND(`Task not found: ${body.taskDirName}`);
|
||||||
|
const mode = task.mode as 'pipeline' | 'script' | 'agentic';
|
||||||
|
if (mode !== 'script' && mode !== 'pipeline') throw errors.BAD_REQUEST(`Task mode '${mode}' can't run as a job yet`);
|
||||||
|
|
||||||
|
const { jobId, status } = await jobManager.enqueueJob(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username ?? '',
|
||||||
|
role: user.role ?? '',
|
||||||
|
mode,
|
||||||
|
sandboxed: (user.role ?? '') !== 'Super Admin',
|
||||||
|
taskDirName: body.taskDirName,
|
||||||
|
taskName: task.name,
|
||||||
|
inputs: body.inputs ?? {},
|
||||||
|
cwd: body.cwd,
|
||||||
|
config: task.config,
|
||||||
|
},
|
||||||
|
body.action === 'queue' ? 'queue' : 'start',
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ jobId, status });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /:id/log?offset= — tail the persisted output log (script jobs). Returns text from `offset`.
|
||||||
|
pipelineJobsRouter.get('/:id/log', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const job = await getPipelineJob(c.req.param('id'));
|
||||||
|
if (!job || job.userId !== user.id) return c.json({ error: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const offset = Math.max(0, parseInt(c.req.query('offset') ?? '0', 10) || 0);
|
||||||
|
const path = jobLogPath(job.id);
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
size = (await stat(path)).size;
|
||||||
|
} catch {
|
||||||
|
return c.json({ text: '', offset, size: 0 }); // no log yet
|
||||||
|
}
|
||||||
|
if (offset >= size) return c.json({ text: '', offset: size, size });
|
||||||
|
|
||||||
|
const fh = await open(path, 'r');
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(size - offset);
|
||||||
|
await fh.read(buf, 0, buf.length, offset);
|
||||||
|
return c.json({ text: buf.toString('utf8'), offset: size, size });
|
||||||
|
} finally {
|
||||||
|
await fh.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/stop — request cancellation (cooperative abort).
|
||||||
|
pipelineJobsRouter.post('/:id/stop', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const job = await getPipelineJob(c.req.param('id'));
|
||||||
|
if (!job || job.userId !== user.id) return c.json({ error: 'Not found' }, 404);
|
||||||
|
const stopped = jobManager.stopJob(job.id);
|
||||||
|
return c.json({ ok: true, wasLive: stopped });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /:id — single job detail (full row).
|
||||||
pipelineJobsRouter.get('/:id', async (c) => {
|
pipelineJobsRouter.get('/:id', async (c) => {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const job = await getPipelineJob(c.req.param('id'));
|
const job = await getPipelineJob(c.req.param('id'));
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ protectedRouter.route('/apps', appsRouter);
|
|||||||
protectedRouter.route('/bug-report', bugReportRouter);
|
protectedRouter.route('/bug-report', bugReportRouter);
|
||||||
protectedRouter.route('/saved-sessions', savedSessionsRouter);
|
protectedRouter.route('/saved-sessions', savedSessionsRouter);
|
||||||
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
||||||
|
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
||||||
desktopRouter.use(superAdminMiddleware);
|
desktopRouter.use(superAdminMiddleware);
|
||||||
protectedRouter.route('/desktop', desktopRouter);
|
protectedRouter.route('/desktop', desktopRouter);
|
||||||
protectedRouter.route('/', piRestRouter);
|
protectedRouter.route('/', piRestRouter);
|
||||||
|
|||||||
Reference in New Issue
Block a user