@syntheos/chiasm 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ import Database from "libsql";
2
+
3
+ export function initDb(dbPath: string): Database {
4
+ const db = new Database(dbPath);
5
+
6
+ db.exec("PRAGMA foreign_keys = ON");
7
+
8
+ db.exec(`
9
+ CREATE TABLE IF NOT EXISTS tasks (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ agent TEXT NOT NULL,
12
+ project TEXT NOT NULL,
13
+ title TEXT NOT NULL,
14
+ status TEXT NOT NULL DEFAULT 'active',
15
+ summary TEXT,
16
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
17
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
18
+ );
19
+
20
+ CREATE TABLE IF NOT EXISTS task_updates (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
23
+ agent TEXT NOT NULL,
24
+ status TEXT NOT NULL,
25
+ summary TEXT,
26
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
30
+ CREATE INDEX IF NOT EXISTS idx_tasks_agent ON tasks(agent);
31
+ CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project);
32
+ CREATE INDEX IF NOT EXISTS idx_task_updates_task_id ON task_updates(task_id);
33
+
34
+ CREATE TABLE IF NOT EXISTS agent_keys (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ agent TEXT NOT NULL,
37
+ key_hash TEXT NOT NULL UNIQUE,
38
+ key_prefix TEXT NOT NULL,
39
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
40
+ last_used_at TEXT,
41
+ revoked INTEGER NOT NULL DEFAULT 0
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_agent_keys_hash ON agent_keys(key_hash);
44
+
45
+ CREATE TABLE IF NOT EXISTS path_claims (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
48
+ agent TEXT NOT NULL,
49
+ project TEXT NOT NULL,
50
+ path TEXT NOT NULL,
51
+ claimed_at TEXT NOT NULL DEFAULT (datetime('now')),
52
+ expires_at TEXT NOT NULL,
53
+ released INTEGER NOT NULL DEFAULT 0
54
+ );
55
+ CREATE INDEX IF NOT EXISTS idx_path_claims_project_path ON path_claims(project, path);
56
+ CREATE INDEX IF NOT EXISTS idx_path_claims_task_id ON path_claims(task_id);
57
+ CREATE INDEX IF NOT EXISTS idx_path_claims_expires ON path_claims(expires_at);
58
+
59
+ CREATE TABLE IF NOT EXISTS task_dependencies (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
62
+ depends_on INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
63
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
64
+ UNIQUE(task_id, depends_on)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_dependencies(task_id);
67
+ CREATE INDEX IF NOT EXISTS idx_task_deps_depends ON task_dependencies(depends_on);
68
+ `);
69
+
70
+ // Migrations: add new columns if they don't exist yet
71
+ const migrations = [
72
+ "ALTER TABLE tasks ADD COLUMN expected_output TEXT",
73
+ "ALTER TABLE tasks ADD COLUMN output_format TEXT NOT NULL DEFAULT 'raw'",
74
+ "ALTER TABLE tasks ADD COLUMN output TEXT",
75
+ "ALTER TABLE tasks ADD COLUMN condition TEXT",
76
+ "ALTER TABLE tasks ADD COLUMN guardrail_url TEXT",
77
+ "ALTER TABLE tasks ADD COLUMN guardrail_retries INTEGER NOT NULL DEFAULT 0",
78
+ "ALTER TABLE tasks ADD COLUMN plan TEXT",
79
+ "ALTER TABLE tasks ADD COLUMN feedback TEXT",
80
+ // Coordination features
81
+ "ALTER TABLE tasks ADD COLUMN last_heartbeat TEXT",
82
+ "ALTER TABLE tasks ADD COLUMN heartbeat_interval INTEGER NOT NULL DEFAULT 300",
83
+ "ALTER TABLE tasks ADD COLUMN assigned INTEGER NOT NULL DEFAULT 1",
84
+ ];
85
+ for (const stmt of migrations) {
86
+ try { db.exec(stmt); } catch { /* column already exists */ }
87
+ }
88
+
89
+ return db;
90
+ }
@@ -0,0 +1,510 @@
1
+ import type DatabaseConstructor from "libsql";
2
+ type Database = InstanceType<typeof DatabaseConstructor>;
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import type { AuthIdentity } from "../types.ts";
5
+ import {
6
+ listTasks,
7
+ createTask,
8
+ updateTask,
9
+ deleteTask,
10
+ getTask,
11
+ getFeed,
12
+ submitOutput,
13
+ submitFeedback,
14
+ createClaims,
15
+ releaseClaims,
16
+ releaseClaimsByPath,
17
+ checkConflicts,
18
+ getClaimsForTask,
19
+ getClaimsForProject,
20
+ recordHeartbeat,
21
+ addDependencies,
22
+ removeDependency,
23
+ getDependencies,
24
+ enqueueTask,
25
+ claimNextTask,
26
+ getChiasmStats,
27
+ } from "../db/queries.ts";
28
+
29
+ export interface RouteOptions {
30
+ bodyMaxBytes: number;
31
+ tasksDefaultLimit: number;
32
+ tasksMaxLimit: number;
33
+ feedDefaultLimit: number;
34
+ feedMaxLimit: number;
35
+ taskUpdateMaxRows: number;
36
+ taskUpdateMaxAgeDays: number;
37
+ // Engram URL for AI planning
38
+ engramUrl?: string;
39
+ engramApiKey?: string;
40
+ }
41
+
42
+ const DEFAULT_ROUTE_OPTIONS: RouteOptions = {
43
+ bodyMaxBytes: 64 * 1024,
44
+ tasksDefaultLimit: 500,
45
+ tasksMaxLimit: 1000,
46
+ feedDefaultLimit: 50,
47
+ feedMaxLimit: 200,
48
+ taskUpdateMaxRows: 5000,
49
+ taskUpdateMaxAgeDays: 30,
50
+ };
51
+
52
+ const VALID_STATUSES = new Set(["active", "paused", "blocked", "completed", "blocked_on_human", "stale", "queued"]);
53
+
54
+ function json(res: ServerResponse, data: unknown, status = 200) {
55
+ res.writeHead(status, { "Content-Type": "application/json" });
56
+ res.end(JSON.stringify(data));
57
+ }
58
+
59
+ function error(res: ServerResponse, message: string, status = 400) {
60
+ json(res, { error: message }, status);
61
+ }
62
+
63
+ async function readBody(req: IncomingMessage, maxBytes: number): Promise<Record<string, unknown>> {
64
+ return new Promise((resolve, reject) => {
65
+ const chunks: Buffer[] = [];
66
+ let totalBytes = 0;
67
+ let settled = false;
68
+
69
+ const onData = (chunk: Buffer) => {
70
+ if (settled) return;
71
+ totalBytes += chunk.length;
72
+ if (totalBytes > maxBytes) {
73
+ settled = true;
74
+ req.off("data", onData);
75
+ req.off("end", onEnd);
76
+ req.off("error", onError);
77
+ req.resume();
78
+ reject(new Error("Request body too large"));
79
+ return;
80
+ }
81
+ chunks.push(chunk);
82
+ };
83
+
84
+ const onEnd = () => {
85
+ if (settled) return;
86
+ settled = true;
87
+ if (chunks.length === 0) { resolve({}); return; }
88
+ try {
89
+ const parsed = JSON.parse(Buffer.concat(chunks).toString());
90
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
91
+ reject(new Error("Request body must be a JSON object"));
92
+ return;
93
+ }
94
+ resolve(parsed as Record<string, unknown>);
95
+ } catch {
96
+ reject(new Error("Invalid JSON"));
97
+ }
98
+ };
99
+
100
+ const onError = (err: Error) => { if (settled) return; settled = true; reject(err); };
101
+
102
+ req.on("data", onData);
103
+ req.on("end", onEnd);
104
+ req.on("error", onError);
105
+ });
106
+ }
107
+
108
+ function parseBoundedInt(value: string | null, fallback: number, min: number, max: number): number {
109
+ const parsed = Number.parseInt(value ?? "", 10);
110
+ if (!Number.isFinite(parsed)) return fallback;
111
+ return Math.min(Math.max(parsed, min), max);
112
+ }
113
+
114
+ function requestErrorStatus(err: unknown): number {
115
+ return err instanceof Error && err.message === "Request body too large" ? 413 : 400;
116
+ }
117
+
118
+ function canActOnAgent(identity: AuthIdentity, taskAgent: string): boolean {
119
+ if (identity.role === "admin") return true;
120
+ return identity.agent === taskAgent;
121
+ }
122
+
123
+ // Generate an execution plan via Engram LLM
124
+ async function generatePlan(task: ReturnType<typeof getTask>, opts: RouteOptions): Promise<string> {
125
+ if (!opts.engramUrl || !task) throw new Error("Engram URL not configured");
126
+
127
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
128
+ if (opts.engramApiKey) headers["Authorization"] = `Bearer ${opts.engramApiKey}`;
129
+
130
+ const prompt = `Create a concise step-by-step execution plan for this task:
131
+
132
+ Title: ${task.title}
133
+ Project: ${task.project}
134
+ Agent: ${task.agent}
135
+ ${task.expected_output ? `Expected output: ${task.expected_output}` : ""}
136
+ ${task.summary ? `Context: ${task.summary}` : ""}
137
+
138
+ Respond with a numbered list of concrete steps. Be specific and actionable.`;
139
+
140
+ const res = await fetch(`${opts.engramUrl}/llm`, {
141
+ method: "POST",
142
+ headers,
143
+ body: JSON.stringify({ prompt, system: "You are a precise task planner. Return only a numbered list of steps." }),
144
+ signal: AbortSignal.timeout(30000),
145
+ });
146
+
147
+ if (!res.ok) throw new Error(`Engram LLM HTTP ${res.status}`);
148
+ const data = await res.json() as any;
149
+ return data.result ?? data.text ?? data.content ?? JSON.stringify(data);
150
+ }
151
+
152
+ export function handleTaskRoutes(
153
+ db: Database,
154
+ req: IncomingMessage,
155
+ res: ServerResponse,
156
+ pathname: string,
157
+ options: RouteOptions = DEFAULT_ROUTE_OPTIONS,
158
+ identity?: AuthIdentity,
159
+ ) {
160
+ const url = new URL(req.url!, `http://${req.headers.host}`);
161
+ const auth: AuthIdentity = identity ?? { role: "admin", agent: null };
162
+
163
+ // GET /tasks
164
+ if (pathname === "/tasks" && req.method === "GET") {
165
+ return json(res, listTasks(db, {
166
+ agent: url.searchParams.get("agent") ?? undefined,
167
+ project: url.searchParams.get("project") ?? undefined,
168
+ status: url.searchParams.get("status") ?? undefined,
169
+ limit: parseBoundedInt(url.searchParams.get("limit"), options.tasksDefaultLimit, 1, options.tasksMaxLimit),
170
+ offset: parseBoundedInt(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER),
171
+ }));
172
+ }
173
+
174
+ // POST /tasks
175
+ if (pathname === "/tasks" && req.method === "POST") {
176
+ return readBody(req, options.bodyMaxBytes).then((body) => {
177
+ const {
178
+ agent, project, title, summary,
179
+ expected_output, output_format, condition, guardrail_url,
180
+ } = body as {
181
+ agent?: string; project?: string; title?: string; summary?: string;
182
+ expected_output?: string; output_format?: string; condition?: string; guardrail_url?: string;
183
+ };
184
+
185
+ if (!agent || !project || !title) return error(res, "agent, project, and title are required");
186
+ if (typeof agent !== "string" || typeof project !== "string" || typeof title !== "string") {
187
+ return error(res, "agent, project, and title must be strings");
188
+ }
189
+ if (!canActOnAgent(auth, agent)) {
190
+ return error(res, `Agent key for "${auth.agent}" cannot create tasks for "${agent}"`, 403);
191
+ }
192
+
193
+ const task = createTask(db, { agent, project, title, summary, expected_output, output_format, condition, guardrail_url });
194
+ return json(res, task, 201);
195
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
196
+ }
197
+
198
+ // GET /tasks/stats
199
+ if (pathname === "/tasks/stats" && req.method === "GET") {
200
+ return json(res, getChiasmStats(db));
201
+ }
202
+
203
+ const taskMatch = pathname.match(/^\/tasks\/(\d+)$/);
204
+ const taskId = taskMatch ? parseInt(taskMatch[1], 10) : null;
205
+
206
+ // GET /tasks/:id
207
+ if (taskMatch && req.method === "GET") {
208
+ const task = getTask(db, taskId!);
209
+ if (!task) return error(res, "Task not found", 404);
210
+ return json(res, task);
211
+ }
212
+
213
+ // PATCH /tasks/:id
214
+ if (taskMatch && req.method === "PATCH") {
215
+ return readBody(req, options.bodyMaxBytes).then((body) => {
216
+ const existing = getTask(db, taskId!);
217
+ if (!existing) return error(res, "Task not found", 404);
218
+ if (!canActOnAgent(auth, existing.agent)) {
219
+ return error(res, `Agent key for "${auth.agent}" cannot update tasks owned by "${existing.agent}"`, 403);
220
+ }
221
+
222
+ const bodyData = body as { status?: unknown; summary?: unknown; agent?: unknown; plan?: unknown; feedback?: unknown };
223
+ if (bodyData.agent !== undefined) return error(res, "agent cannot be updated");
224
+
225
+ if (typeof bodyData.status === "string" && !VALID_STATUSES.has(bodyData.status)) {
226
+ return error(res, `Invalid status. Must be one of: ${[...VALID_STATUSES].join(", ")}`);
227
+ }
228
+
229
+ // Hard reject: completing a task requires a non-blank summary
230
+ if (bodyData.status === "completed") {
231
+ const summary = (typeof bodyData.summary === "string" ? bodyData.summary : (existing.summary ?? "")).trim();
232
+ if (!summary) {
233
+ return error(res, "summary is required when completing a task -- document what you did");
234
+ }
235
+ }
236
+
237
+ const task = updateTask(db, taskId!, bodyData as { status?: string; summary?: string; plan?: string; feedback?: string });
238
+ if (!task) return error(res, "Task not found", 404);
239
+ return json(res, task);
240
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
241
+ }
242
+
243
+ // DELETE /tasks/:id
244
+ if (taskMatch && req.method === "DELETE") {
245
+ const existing = getTask(db, taskId!);
246
+ if (!existing) return error(res, "Task not found", 404);
247
+ if (!canActOnAgent(auth, existing.agent)) {
248
+ return error(res, `Agent key for "${auth.agent}" cannot delete tasks owned by "${existing.agent}"`, 403);
249
+ }
250
+ if (!deleteTask(db, taskId!)) return error(res, "Task not found", 404);
251
+ return json(res, { ok: true });
252
+ }
253
+
254
+ // POST /tasks/:id/output — submit task output, triggers guardrail if configured
255
+ const outputMatch = pathname.match(/^\/tasks\/(\d+)\/output$/);
256
+ if (outputMatch && req.method === "POST") {
257
+ return readBody(req, options.bodyMaxBytes).then((body) => {
258
+ const id = parseInt(outputMatch[1], 10);
259
+ const existing = getTask(db, id);
260
+ if (!existing) return error(res, "Task not found", 404);
261
+ if (!canActOnAgent(auth, existing.agent)) {
262
+ return error(res, `Agent key for "${auth.agent}" cannot submit output for tasks owned by "${existing.agent}"`, 403);
263
+ }
264
+
265
+ const output = body.output;
266
+ if (output === undefined) return error(res, "output is required");
267
+ const outputStr = typeof output === "string" ? output : JSON.stringify(output);
268
+
269
+ const task = submitOutput(db, id, outputStr);
270
+ return json(res, task);
271
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
272
+ }
273
+
274
+ // POST /tasks/:id/feedback — human feedback, unblocks blocked_on_human
275
+ const feedbackMatch = pathname.match(/^\/tasks\/(\d+)\/feedback$/);
276
+ if (feedbackMatch && req.method === "POST") {
277
+ return readBody(req, options.bodyMaxBytes).then((body) => {
278
+ const id = parseInt(feedbackMatch[1], 10);
279
+ const existing = getTask(db, id);
280
+ if (!existing) return error(res, "Task not found", 404);
281
+
282
+ const feedback = body.feedback;
283
+ if (!feedback || typeof feedback !== "string") return error(res, "feedback string is required");
284
+
285
+ const task = submitFeedback(db, id, feedback);
286
+ return json(res, task);
287
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
288
+ }
289
+
290
+ // POST /tasks/:id/plan — generate execution plan via Engram LLM
291
+ const planMatch = pathname.match(/^\/tasks\/(\d+)\/plan$/);
292
+ if (planMatch && req.method === "POST") {
293
+ const id = parseInt(planMatch[1], 10);
294
+ const existing = getTask(db, id);
295
+ if (!existing) return error(res, "Task not found", 404);
296
+ if (!canActOnAgent(auth, existing.agent)) {
297
+ return error(res, `Agent key for "${auth.agent}" cannot plan tasks owned by "${existing.agent}"`, 403);
298
+ }
299
+
300
+ return generatePlan(existing, options)
301
+ .then((plan) => {
302
+ const task = updateTask(db, id, { plan });
303
+ return json(res, task);
304
+ })
305
+ .catch((err: any) => error(res, `Plan generation failed: ${err.message}`));
306
+ }
307
+
308
+ // ============================================================================
309
+ // PATH CLAIMS
310
+ // ============================================================================
311
+
312
+ // POST /tasks/:id/claims — create path claims
313
+ const claimsMatch = pathname.match(/^\/tasks\/(\d+)\/claims$/);
314
+ if (claimsMatch && req.method === "POST") {
315
+ return readBody(req, options.bodyMaxBytes).then((body) => {
316
+ const id = parseInt(claimsMatch[1], 10);
317
+ const existing = getTask(db, id);
318
+ if (!existing) return error(res, "Task not found", 404);
319
+ if (!canActOnAgent(auth, existing.agent)) {
320
+ return error(res, `Agent key for "${auth.agent}" cannot manage claims for "${existing.agent}"`, 403);
321
+ }
322
+
323
+ const paths = body.paths;
324
+ if (!Array.isArray(paths) || paths.length === 0 || !paths.every(p => typeof p === "string")) {
325
+ return error(res, "paths must be a non-empty array of strings");
326
+ }
327
+ const ttl = typeof body.ttl === "number" ? body.ttl : undefined;
328
+ const force = body.force === true;
329
+
330
+ const conflicts = checkConflicts(db, existing.project, paths as string[], id);
331
+ if (conflicts.length > 0 && !force) {
332
+ return json(res, { error: "Path conflicts detected", conflicts }, 409);
333
+ }
334
+
335
+ const claims = createClaims(db, id, existing.agent, existing.project, paths as string[], ttl);
336
+ return json(res, { claims, conflicts: conflicts.length > 0 ? conflicts : undefined }, 201);
337
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
338
+ }
339
+
340
+ // DELETE /tasks/:id/claims — release claims
341
+ if (claimsMatch && req.method === "DELETE") {
342
+ const id = parseInt(claimsMatch[1], 10);
343
+ const existing = getTask(db, id);
344
+ if (!existing) return error(res, "Task not found", 404);
345
+ if (!canActOnAgent(auth, existing.agent)) {
346
+ return error(res, `Agent key for "${auth.agent}" cannot manage claims for "${existing.agent}"`, 403);
347
+ }
348
+
349
+ // Try to read body for selective release, fall back to release all
350
+ return readBody(req, options.bodyMaxBytes).then((body) => {
351
+ const paths = body.paths;
352
+ if (Array.isArray(paths) && paths.length > 0) {
353
+ const released = releaseClaimsByPath(db, id, paths as string[]);
354
+ return json(res, { released });
355
+ }
356
+ const released = releaseClaims(db, id);
357
+ return json(res, { released });
358
+ }).catch(() => {
359
+ const released = releaseClaims(db, id);
360
+ return json(res, { released });
361
+ });
362
+ }
363
+
364
+ // GET /tasks/:id/claims — list claims for a task
365
+ if (claimsMatch && req.method === "GET") {
366
+ const id = parseInt(claimsMatch[1], 10);
367
+ const existing = getTask(db, id);
368
+ if (!existing) return error(res, "Task not found", 404);
369
+ return json(res, getClaimsForTask(db, id));
370
+ }
371
+
372
+ // POST /claims/check — pre-flight conflict check
373
+ if (pathname === "/claims/check" && req.method === "POST") {
374
+ return readBody(req, options.bodyMaxBytes).then((body) => {
375
+ const project = body.project;
376
+ const paths = body.paths;
377
+ const excludeTask = typeof body.exclude_task === "number" ? body.exclude_task : undefined;
378
+
379
+ if (!project || typeof project !== "string") return error(res, "project is required");
380
+ if (!Array.isArray(paths) || paths.length === 0) return error(res, "paths must be a non-empty array");
381
+
382
+ const conflicts = checkConflicts(db, project, paths as string[], excludeTask);
383
+ return json(res, { conflicts, has_conflicts: conflicts.length > 0 });
384
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
385
+ }
386
+
387
+ // GET /claims?project=X — all active claims for a project
388
+ if (pathname === "/claims" && req.method === "GET") {
389
+ const project = url.searchParams.get("project");
390
+ if (!project) return error(res, "project query parameter is required");
391
+ return json(res, getClaimsForProject(db, project));
392
+ }
393
+
394
+ // ============================================================================
395
+ // HEARTBEAT
396
+ // ============================================================================
397
+
398
+ // POST /tasks/:id/heartbeat
399
+ const heartbeatMatch = pathname.match(/^\/tasks\/(\d+)\/heartbeat$/);
400
+ if (heartbeatMatch && req.method === "POST") {
401
+ const id = parseInt(heartbeatMatch[1], 10);
402
+ const existing = getTask(db, id);
403
+ if (!existing) return error(res, "Task not found", 404);
404
+ if (!canActOnAgent(auth, existing.agent)) {
405
+ return error(res, `Agent key for "${auth.agent}" cannot heartbeat for "${existing.agent}"`, 403);
406
+ }
407
+
408
+ const task = recordHeartbeat(db, id);
409
+ if (!task) return error(res, "Task not found", 404);
410
+ return json(res, task);
411
+ }
412
+
413
+ // ============================================================================
414
+ // DEPENDENCIES
415
+ // ============================================================================
416
+
417
+ // POST /tasks/:id/dependencies — add dependencies
418
+ const depsMatch = pathname.match(/^\/tasks\/(\d+)\/dependencies$/);
419
+ if (depsMatch && req.method === "POST") {
420
+ return readBody(req, options.bodyMaxBytes).then((body) => {
421
+ const id = parseInt(depsMatch[1], 10);
422
+ const existing = getTask(db, id);
423
+ if (!existing) return error(res, "Task not found", 404);
424
+ if (!canActOnAgent(auth, existing.agent)) {
425
+ return error(res, `Agent key for "${auth.agent}" cannot manage dependencies for "${existing.agent}"`, 403);
426
+ }
427
+
428
+ const dependsOn = body.depends_on;
429
+ if (!Array.isArray(dependsOn) || dependsOn.length === 0 || !dependsOn.every(d => typeof d === "number")) {
430
+ return error(res, "depends_on must be a non-empty array of task IDs");
431
+ }
432
+
433
+ try {
434
+ addDependencies(db, id, dependsOn as number[]);
435
+ const deps = getDependencies(db, id);
436
+ return json(res, { dependencies: deps }, 201);
437
+ } catch (e: any) {
438
+ return error(res, e.message);
439
+ }
440
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
441
+ }
442
+
443
+ // GET /tasks/:id/dependencies
444
+ if (depsMatch && req.method === "GET") {
445
+ const id = parseInt(depsMatch[1], 10);
446
+ const existing = getTask(db, id);
447
+ if (!existing) return error(res, "Task not found", 404);
448
+ return json(res, { dependencies: getDependencies(db, id) });
449
+ }
450
+
451
+ // DELETE /tasks/:id/dependencies/:depId
452
+ const depDeleteMatch = pathname.match(/^\/tasks\/(\d+)\/dependencies\/(\d+)$/);
453
+ if (depDeleteMatch && req.method === "DELETE") {
454
+ const id = parseInt(depDeleteMatch[1], 10);
455
+ const depId = parseInt(depDeleteMatch[2], 10);
456
+ const existing = getTask(db, id);
457
+ if (!existing) return error(res, "Task not found", 404);
458
+ if (!canActOnAgent(auth, existing.agent)) {
459
+ return error(res, `Agent key for "${auth.agent}" cannot manage dependencies for "${existing.agent}"`, 403);
460
+ }
461
+
462
+ if (!removeDependency(db, id, depId)) return error(res, "Dependency not found", 404);
463
+ return json(res, { ok: true });
464
+ }
465
+
466
+ // ============================================================================
467
+ // WORK QUEUE
468
+ // ============================================================================
469
+
470
+ // POST /queue — enqueue unassigned task (admin only)
471
+ if (pathname === "/queue" && req.method === "POST") {
472
+ if (auth.role !== "admin") return error(res, "Admin access required to enqueue tasks", 403);
473
+ return readBody(req, options.bodyMaxBytes).then((body) => {
474
+ const { project, title, summary, expected_output, condition } = body as {
475
+ project?: string; title?: string; summary?: string; expected_output?: string; condition?: string;
476
+ };
477
+ if (!project || !title) return error(res, "project and title are required");
478
+ if (typeof project !== "string" || typeof title !== "string") return error(res, "project and title must be strings");
479
+
480
+ const task = enqueueTask(db, { project, title, summary, expected_output, condition });
481
+ return json(res, task, 201);
482
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
483
+ }
484
+
485
+ // POST /queue/claim — claim next available task
486
+ if (pathname === "/queue/claim" && req.method === "POST") {
487
+ return readBody(req, options.bodyMaxBytes).then((body) => {
488
+ const agent = typeof body.agent === "string" ? body.agent : auth.agent;
489
+ if (!agent) return error(res, "agent is required");
490
+ if (!canActOnAgent(auth, agent)) {
491
+ return error(res, `Agent key for "${auth.agent}" cannot claim tasks for "${agent}"`, 403);
492
+ }
493
+
494
+ const project = typeof body.project === "string" ? body.project : undefined;
495
+ const task = claimNextTask(db, agent, project);
496
+ if (!task) return json(res, { message: "No tasks available in queue" }, 204);
497
+ return json(res, task);
498
+ }).catch((err) => error(res, err instanceof Error ? err.message : "Invalid request body", requestErrorStatus(err)));
499
+ }
500
+
501
+ // GET /feed
502
+ if (pathname === "/feed" && req.method === "GET") {
503
+ return json(res, getFeed(db,
504
+ parseBoundedInt(url.searchParams.get("limit"), options.feedDefaultLimit, 1, options.feedMaxLimit),
505
+ parseBoundedInt(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER),
506
+ ));
507
+ }
508
+
509
+ error(res, "Not found", 404);
510
+ }