jobs: queue scheduler + REST job API (phase 1c + 2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +1,106 @@
|
||||
import { stat, open } from 'node:fs/promises';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '../../custom-errors';
|
||||
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();
|
||||
|
||||
// GET /pipeline-jobs — list user's pipeline jobs
|
||||
// GET / — list the user's jobs (optionally only the live ones).
|
||||
pipelineJobsRouter.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const jobs = await getPipelineJobsForUser(user.id);
|
||||
return c.json(jobs.map((j) => ({
|
||||
id: j.id,
|
||||
taskDirName: j.taskDirName,
|
||||
taskName: j.taskName,
|
||||
status: j.status,
|
||||
totalCost: j.totalCost,
|
||||
createdAt: j.createdAt,
|
||||
startedAt: j.startedAt,
|
||||
completedAt: j.completedAt,
|
||||
error: j.error,
|
||||
})));
|
||||
const liveOnly = c.req.query('live') === '1';
|
||||
const jobs = await jobManager.getJobsForUser(user.id);
|
||||
return c.json(
|
||||
jobs
|
||||
.filter((j) => (liveOnly ? j.isLive : true))
|
||||
.map((j) => ({
|
||||
id: j.id,
|
||||
mode: j.mode,
|
||||
taskDirName: j.taskDirName,
|
||||
taskName: j.taskName,
|
||||
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) => {
|
||||
const user = c.get('user');
|
||||
const job = await getPipelineJob(c.req.param('id'));
|
||||
|
||||
Reference in New Issue
Block a user