import { createRouter } from '../../create-router'; import * as errors from '../../custom-errors'; import { listTasks, getTaskByDirName, createTask, deleteTask, readCategoryOrder } from './task-files'; import { runScriptTaskDirect } from './script-run'; type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; export const tasksRouter = createRouter(); tasksRouter.get('/', async (ctx) => { const summaries = await listTasks(); const tasks = summaries.map((row) => ({ dirName: row.dirName, name: row.name, description: row.description, category: row.category, triggers: (row.trigger as TriggerConfig[]) ?? [], mode: row.mode ?? 'agentic', plugin: row.plugin, inline: row.inline, inputs: row.inputs, })); return ctx.json(tasks); }); // Must be declared before '/:name', which would otherwise match "categories". tasksRouter.get('/categories', async (ctx) => { return ctx.json(await readCategoryOrder()); }); tasksRouter.get('/:name', async (ctx) => { const name = ctx.req.param('name'); const task = await getTaskByDirName(name); if (!task) return ctx.text('Not found', 404); return ctx.json({ dirName: task.dirName, name: task.name, description: task.description, category: task.category, mode: task.mode, inline: task.inline, language: task.language, body: task.body, implementation: task.implementation, inputs: task.inputs, args: task.args, trigger: task.trigger, config: task.config, version: task.version, filePath: task.filePath, plugin: task.plugin, }); }); /** * POST /tasks/:name/run — run a script task, wait for it, return what it did. * * The entry point for a caller with no browser. Both existing runners need something watching them: the * socket needs a live connection to stream into, and a job needs somebody to open Jobs and read it. A * commander's loop has neither — it holds a credential, makes one call, and acts on the answer. * * Deliberately NOT a job. Transcribing a memo takes seconds and produces a file; wrapping it in a queue, * a row and a progress bar puts a screen nobody opened between the request and the result. * * Synchronous, so the caller's own timeout is the bound on how long a task may take. That is the honest * shape for "do this and tell me what happened" — a task that needs to outlive its caller should be a * job, and `POST /jobs` already is one. */ tasksRouter.post('/:name/run', async (ctx) => { const user = ctx.get('user'); const name = ctx.req.param('name'); const task = await getTaskByDirName(name); if (!task) throw errors.NOT_FOUND(`Task not found: ${name}`); if (task.mode !== 'script') throw errors.BAD_REQUEST(`Task mode '${task.mode}' cannot run directly`); type RunBody = { inputs?: Record; cwd?: string }; // A run with no inputs is a legitimate call, so an absent or unparseable body is an empty one rather // than a 400 — the task's own required-input check is the place that knows what it needs. const body: RunBody = await ctx.req.json().catch(() => ({}) as RunBody); try { const { code, output, taskName } = await runScriptTaskDirect({ taskDirName: name, inputs: body.inputs ?? {}, cwd: body.cwd, // The script inherits the caller's narrowing, or the absence of one. Without this a narrowed // credential could write a task and run it back at the API with full authority. user: { id: user.id, email: user.email, username: user.username ?? '', scope: ctx.get('credentialScope'), }, }); // A non-zero exit is reported, not thrown: the script ran, and its output is the useful part of the // answer either way. HTTP 200 means "the run happened"; `code` means "it worked". return ctx.json({ ok: code === 0, code, output, taskName }); } catch (err) { throw errors.BAD_REQUEST(err instanceof Error ? err.message : 'Task failed to start'); } }); tasksRouter.post('/', async (ctx) => { const body = await ctx.req.json<{ name: string; description?: string; mode?: string; language?: string }>(); if (!body.name?.trim()) return ctx.text('Name is required', 400); try { const task = await createTask({ name: body.name, description: body.description ?? null, mode: body.mode ?? 'agentic', language: body.language ?? null, }); return ctx.json(task); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create task'; return ctx.text(message, message === 'Task already exists' ? 409 : 400); } }); tasksRouter.delete('/:name', async (ctx) => { const name = ctx.req.param('name'); const task = await getTaskByDirName(name); if (!task) return ctx.text('Not found', 404); // A plugin's task is its own file in its own checkout. Uninstalling the plugin is how it goes away. if (task.plugin) return ctx.text(`${name} belongs to the ${task.plugin} plugin — uninstall it instead`, 409); await deleteTask(task.dirName); return ctx.json({ ok: true }); });