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 / — list the user's jobs (optionally only the live ones). pipelineJobsRouter.get('/', async (c) => { const user = c.get('user'); 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, // The file/folder the job is working on (for the list rows). target: (j.inputs as Record | null)?.file_path ?? j.cwd ?? null, totalCost: j.totalCost, createdAt: j.createdAt, startedAt: j.startedAt, completedAt: j.completedAt, error: j.error, })), ); }); // 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; 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 ?? '', mode, 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 /counts — header-badge summary { running, runningJobId, queued }. Before /:id so it isn't // captured as an id. pipelineJobsRouter.get('/counts', async (c) => { const user = c.get('user'); return c.json(await jobManager.getCounts(user.id)); }); // 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 — stop a running job or cancel a queued one. 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 result = await jobManager.requestStop(job.id); return c.json({ ok: true, result }); }); // DELETE /history — clear all finished jobs (rows + logs). Before /:id so it isn't captured as an id. pipelineJobsRouter.delete('/history', async (c) => { const user = c.get('user'); const cleared = await jobManager.clearHistory(user.id); return c.json({ ok: true, cleared }); }); // DELETE /:id — delete a job (queued or finished). Running jobs must be stopped first (409). pipelineJobsRouter.delete('/:id', 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 result = await jobManager.deleteJob(job.id); if (result === 'running') return c.json({ error: 'Stop the job before deleting' }, 409); return c.json({ ok: true, result }); }); // 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')); if (!job || job.userId !== user.id) return c.json({ error: 'Not found' }, 404); return c.json(job); });