The permission_requested WS frame now carries kind ('tool'|'question'|'plan'|
'elicitation'), input (the tool's rawInput payload), and description fields.
PermissionCard detects question-type permissions (Claude Code's AskUserQuestion)
and renders an interactive radio/checkbox form instead of approve/deny buttons.
Submitting answers auto-selects the first allow option.
Also wires up ACP createElicitation (unstable/experimental) — JSON Schema-driven
forms for structured user input. The same PermissionCard renders elicitation
fields with type-appropriate inputs. Both flows use the existing permission-waiter
blocking pattern with 120s timeout.
The response path (POST /api/coder/tasks/:id/permission) now accepts optional
updated_input alongside option_id, forwarded to the ACP agent as the user's
answer payload. Elicitation responses map to accept/decline/cancel actions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
186 lines
6.5 KiB
TypeScript
186 lines
6.5 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
import { getPendingPermission, respondToPermission, cancelPendingPermission } from '../services/permission-waiter.js';
|
|
import { getTaskCommands } from '../services/agent-commands-cache.js';
|
|
|
|
interface InferenceApi {
|
|
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
|
}
|
|
|
|
const CreateBody = z.object({
|
|
project_id: z.string().uuid(),
|
|
input: z.string().min(1).max(64_000),
|
|
agent: z.string().max(100).optional(),
|
|
model: z.string().max(200).optional(),
|
|
mode_id: z.string().max(200).optional(),
|
|
thinking_option_id: z.string().max(200).optional(),
|
|
});
|
|
|
|
const PermissionBody = z.object({
|
|
option_id: z.string().max(200).nullable(),
|
|
updated_input: z.record(z.unknown()).optional(),
|
|
});
|
|
|
|
const ListQuery = z.object({
|
|
state: z.enum(['pending', 'running', 'completed', 'failed', 'blocked', 'cancelled']).optional(),
|
|
project_id: z.string().uuid().optional(),
|
|
});
|
|
|
|
export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void {
|
|
// POST /api/tasks — create a new task
|
|
app.post('/api/tasks', async (req, reply) => {
|
|
const parsed = CreateBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
|
|
const { project_id, input, agent, model, mode_id, thinking_option_id } = parsed.data;
|
|
|
|
const [task] = await sql<{ id: string; state: string }[]>`
|
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id)
|
|
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null})
|
|
RETURNING id, state
|
|
`;
|
|
|
|
reply.code(201);
|
|
return { id: task!.id, state: task!.state };
|
|
});
|
|
|
|
// GET /api/tasks — list tasks with optional filters
|
|
app.get('/api/tasks', async (req, _reply) => {
|
|
const parsed = ListQuery.safeParse(req.query);
|
|
if (!parsed.success) {
|
|
return { error: 'invalid query', details: parsed.error.flatten() };
|
|
}
|
|
|
|
const { state, project_id } = parsed.data;
|
|
|
|
// Build query with optional filters
|
|
if (state && project_id) {
|
|
return sql`
|
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
|
FROM tasks
|
|
WHERE state = ${state} AND project_id = ${project_id}
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
} else if (state) {
|
|
return sql`
|
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
|
FROM tasks
|
|
WHERE state = ${state}
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
} else if (project_id) {
|
|
return sql`
|
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
|
FROM tasks
|
|
WHERE project_id = ${project_id}
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
} else {
|
|
return sql`
|
|
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
|
FROM tasks
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
}
|
|
});
|
|
|
|
// GET /api/tasks/:id — single task detail
|
|
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => {
|
|
const rows = await sql`
|
|
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, worktree_path, session_id, cost_tokens, started_at, ended_at, created_at
|
|
FROM tasks
|
|
WHERE id = ${req.params.id}
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'task not found' };
|
|
}
|
|
return rows[0];
|
|
});
|
|
|
|
// POST /api/tasks/:id/cancel — cancel a pending or running task
|
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/cancel', async (req, reply) => {
|
|
const taskId = req.params.id;
|
|
|
|
// Get current task state + session info
|
|
const rows = await sql<{ id: string; state: string; session_id: string | null }[]>`
|
|
SELECT id, state, session_id FROM tasks WHERE id = ${taskId}
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'task not found' };
|
|
}
|
|
|
|
const task = rows[0]!;
|
|
if (task.state !== 'pending' && task.state !== 'running' && task.state !== 'blocked') {
|
|
reply.code(409);
|
|
return { error: `cannot cancel task in state '${task.state}'` };
|
|
}
|
|
|
|
cancelPendingPermission(taskId);
|
|
|
|
// If running, try to cancel inference
|
|
if ((task.state === 'running' || task.state === 'blocked') && task.session_id) {
|
|
// Find active chat in the task's session
|
|
const chats = await sql<{ id: string }[]>`
|
|
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
|
|
`;
|
|
for (const chat of chats) {
|
|
await inference.cancel(task.session_id, chat.id);
|
|
}
|
|
}
|
|
|
|
await sql`
|
|
UPDATE tasks
|
|
SET state = 'cancelled', ended_at = clock_timestamp()
|
|
WHERE id = ${taskId} AND state IN ('pending', 'running', 'blocked')
|
|
`;
|
|
|
|
return { cancelled: true };
|
|
});
|
|
|
|
// GET /api/tasks/:id/permission — pending permission prompt (if any)
|
|
app.get<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
|
|
const prompt = getPendingPermission(req.params.id);
|
|
if (!prompt) {
|
|
reply.code(404);
|
|
return { error: 'no pending permission' };
|
|
}
|
|
return prompt;
|
|
});
|
|
|
|
// POST /api/tasks/:id/permission — respond to a pending permission prompt
|
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
|
|
const parsed = PermissionBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
|
|
const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record<string, unknown> | undefined);
|
|
if (!ok) {
|
|
reply.code(404);
|
|
return { error: 'no pending permission' };
|
|
}
|
|
return { ok: true };
|
|
});
|
|
|
|
// GET /api/tasks/:id/commands — cached ACP slash commands (if any)
|
|
app.get<{ Params: { id: string } }>('/api/tasks/:id/commands', async (req, reply) => {
|
|
const commands = getTaskCommands(req.params.id);
|
|
if (!commands?.length) {
|
|
reply.code(404);
|
|
return { error: 'no commands cached' };
|
|
}
|
|
return { taskId: req.params.id, commands };
|
|
});
|
|
}
|