@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.
package/bin.mjs ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, resolve } from "node:path";
5
+ const dir = dirname(fileURLToPath(import.meta.url));
6
+ spawn("node", ["--experimental-strip-types", resolve(dir, "src/server.ts")], { stdio: "inherit" }).on("exit", process.exit);
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@syntheos/chiasm",
3
+ "version": "0.2.0",
4
+ "description": "Multi-agent task coordination",
5
+ "type": "module",
6
+ "engines": { "node": ">=22.6.0" },
7
+ "bin": {
8
+ "syntheos-chiasm": "./bin.mjs"
9
+ },
10
+ "scripts": {
11
+ "dev": "node --experimental-strip-types src/server.ts",
12
+ "start": "node --experimental-strip-types src/server.ts",
13
+ "test": "node --experimental-strip-types --test test/*.test.ts"
14
+ },
15
+ "dependencies": {
16
+ "@opentelemetry/api": "^1.9.0",
17
+ "@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
18
+ "@opentelemetry/instrumentation-http": "^0.213.0",
19
+ "@opentelemetry/resources": "^2.6.0",
20
+ "@opentelemetry/sdk-node": "^0.213.0",
21
+ "@opentelemetry/semantic-conventions": "^1.40.0",
22
+ "libsql": "^0.5.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "typescript": "^5.8.0"
27
+ }
28
+ }
package/src/axon.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Fire-and-forget Axon event emission
2
+ const AXON_URL = process.env.AXON_URL || "";
3
+ const AXON_API_KEY = process.env.AXON_API_KEY || "";
4
+
5
+ export function emitEvent(channel: string, type: string, payload: Record<string, unknown>): void {
6
+ if (!AXON_URL) return;
7
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
8
+ if (AXON_API_KEY) headers["Authorization"] = `Bearer ${AXON_API_KEY}`;
9
+ fetch(`${AXON_URL}/publish`, {
10
+ method: "POST",
11
+ headers,
12
+ body: JSON.stringify({ channel, type, source: "chiasm", payload }),
13
+ signal: AbortSignal.timeout(5000),
14
+ }).catch(() => {});
15
+ }
@@ -0,0 +1,625 @@
1
+ import type DatabaseConstructor from "libsql";
2
+ type Database = InstanceType<typeof DatabaseConstructor>;
3
+ import { startSpan, SpanStatusCode } from "../tracing.ts";
4
+ import { emitEvent } from "../axon.ts";
5
+
6
+ export interface Task {
7
+ id: number;
8
+ agent: string;
9
+ project: string;
10
+ title: string;
11
+ status: string;
12
+ summary: string | null;
13
+ expected_output: string | null;
14
+ output_format: string;
15
+ output: string | null;
16
+ condition: string | null;
17
+ guardrail_url: string | null;
18
+ guardrail_retries: number;
19
+ plan: string | null;
20
+ feedback: string | null;
21
+ last_heartbeat: string | null;
22
+ heartbeat_interval: number;
23
+ assigned: number;
24
+ created_at: string;
25
+ updated_at: string;
26
+ }
27
+
28
+ export interface PathClaim {
29
+ id: number;
30
+ task_id: number;
31
+ agent: string;
32
+ project: string;
33
+ path: string;
34
+ claimed_at: string;
35
+ expires_at: string;
36
+ released: number;
37
+ }
38
+
39
+ export interface PathConflict {
40
+ claim_id: number;
41
+ task_id: number;
42
+ agent: string;
43
+ path: string;
44
+ claimed_path: string;
45
+ }
46
+
47
+ export interface AgentKey {
48
+ id: number;
49
+ agent: string;
50
+ key_hash: string;
51
+ key_prefix: string;
52
+ created_at: string;
53
+ last_used_at: string | null;
54
+ revoked: number;
55
+ }
56
+
57
+ export interface TaskUpdate {
58
+ id: number;
59
+ task_id: number;
60
+ agent: string;
61
+ status: string;
62
+ summary: string | null;
63
+ created_at: string;
64
+ }
65
+
66
+ export interface TaskFilters {
67
+ agent?: string;
68
+ project?: string;
69
+ status?: string;
70
+ limit?: number;
71
+ offset?: number;
72
+ }
73
+
74
+ export function listTasks(db: Database, filters: TaskFilters = {}): Task[] {
75
+ let query = "SELECT * FROM tasks WHERE 1=1";
76
+ const params: Array<string | number> = [];
77
+
78
+ if (filters.agent) { query += " AND agent = ?"; params.push(filters.agent); }
79
+ if (filters.project) { query += " AND project = ?"; params.push(filters.project); }
80
+ if (filters.status) { query += " AND status = ?"; params.push(filters.status); }
81
+
82
+ query += " ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?";
83
+ params.push(filters.limit ?? 500, filters.offset ?? 0);
84
+
85
+ return db.prepare(query).all(...params) as Task[];
86
+ }
87
+
88
+ export function getTask(db: Database, id: number): Task | undefined {
89
+ return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id) as Task | undefined;
90
+ }
91
+
92
+ export function createTask(
93
+ db: Database,
94
+ data: {
95
+ agent: string;
96
+ project: string;
97
+ title: string;
98
+ summary?: string;
99
+ expected_output?: string;
100
+ output_format?: string;
101
+ condition?: string;
102
+ guardrail_url?: string;
103
+ }
104
+ ): Task {
105
+ const span = startSpan("chiasm.createTask", { "task.agent": data.agent, "task.project": data.project });
106
+ const run = db.transaction(() => {
107
+ const result = db.prepare(
108
+ `INSERT INTO tasks (agent, project, title, summary, expected_output, output_format, condition, guardrail_url)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
110
+ ).get(
111
+ data.agent, data.project, data.title,
112
+ data.summary ?? null,
113
+ data.expected_output ?? null,
114
+ data.output_format ?? "raw",
115
+ data.condition ?? null,
116
+ data.guardrail_url ?? null,
117
+ ) as Task;
118
+
119
+ db.prepare(
120
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, 'active', ?)"
121
+ ).run(result.id, data.agent, data.summary ?? null);
122
+
123
+ span.setAttribute("task.id", result.id);
124
+ return result;
125
+ });
126
+
127
+ try {
128
+ const result = run();
129
+ span.setStatus({ code: SpanStatusCode.OK });
130
+ span.end();
131
+ emitEvent("tasks", "task.created", { agent: result.agent, project: result.project, title: result.title, task_id: result.id });
132
+ return result;
133
+ } catch (e: any) {
134
+ span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
135
+ span.recordException(e);
136
+ span.end();
137
+ throw e;
138
+ }
139
+ }
140
+
141
+ export function updateTask(
142
+ db: Database,
143
+ id: number,
144
+ data: { status?: string; summary?: string; plan?: string; feedback?: string }
145
+ ): Task | undefined {
146
+ const existing = getTask(db, id);
147
+ if (!existing) return undefined;
148
+
149
+ const status = data.status ?? existing.status;
150
+ const summary = data.summary ?? existing.summary;
151
+ const plan = data.plan !== undefined ? data.plan : existing.plan;
152
+ const feedback = data.feedback !== undefined ? data.feedback : existing.feedback;
153
+
154
+ const span = startSpan("chiasm.updateTask", { "task.id": id, "task.agent": existing.agent, "task.status": status });
155
+
156
+ const run = db.transaction(() => {
157
+ const result = db.prepare(
158
+ `UPDATE tasks SET status = ?, summary = ?, plan = ?, feedback = ?, updated_at = datetime('now')
159
+ WHERE id = ? RETURNING *`
160
+ ).get(status, summary, plan, feedback, id) as Task;
161
+
162
+ db.prepare(
163
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, ?, ?)"
164
+ ).run(id, existing.agent, status, summary);
165
+
166
+ return result;
167
+ });
168
+
169
+ try {
170
+ const result = run();
171
+ span.setStatus({ code: SpanStatusCode.OK });
172
+ span.end();
173
+ const eventType = result.status === "completed" ? "task.completed" : "task.updated";
174
+ emitEvent("tasks", eventType, { agent: result.agent, project: result.project, title: result.title, task_id: result.id, status: result.status, summary: result.summary ?? undefined });
175
+
176
+ // On completion: release claims and unblock dependents
177
+ if (result.status === "completed" && existing.status !== "completed") {
178
+ releaseClaims(db, id);
179
+ const unblocked = checkAndUnblock(db, id);
180
+ for (const t of unblocked) {
181
+ emitEvent("tasks", "task.unblocked", { agent: t.agent, project: t.project, title: t.title, task_id: t.id });
182
+ }
183
+ }
184
+
185
+ return result;
186
+ } catch (e: any) {
187
+ span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
188
+ span.recordException(e);
189
+ span.end();
190
+ throw e;
191
+ }
192
+ }
193
+
194
+ export function deleteTask(db: Database, id: number): boolean {
195
+ return db.prepare("DELETE FROM tasks WHERE id = ?").run(id).changes > 0;
196
+ }
197
+
198
+ export function submitOutput(db: Database, id: number, output: string): Task | undefined {
199
+ const existing = getTask(db, id);
200
+ if (!existing) return undefined;
201
+
202
+ const result = db.prepare(
203
+ "UPDATE tasks SET output = ?, updated_at = datetime('now') WHERE id = ? RETURNING *"
204
+ ).get(output, id) as Task;
205
+
206
+ db.prepare(
207
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, ?, 'Output submitted')"
208
+ ).run(id, existing.agent, existing.status);
209
+
210
+ emitEvent("tasks", "task.output", { agent: existing.agent, project: existing.project, title: existing.title, task_id: id });
211
+
212
+ if (existing.guardrail_url) {
213
+ runGuardrail(db, result);
214
+ }
215
+
216
+ return result;
217
+ }
218
+
219
+ function runGuardrail(db: Database, task: Task) {
220
+ fetch(task.guardrail_url!, {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/json" },
223
+ body: JSON.stringify({
224
+ task_id: task.id,
225
+ title: task.title,
226
+ expected_output: task.expected_output,
227
+ output: task.output,
228
+ output_format: task.output_format,
229
+ }),
230
+ signal: AbortSignal.timeout(15000),
231
+ })
232
+ .then(async (res) => {
233
+ if (!res.ok) throw new Error(`Guardrail HTTP ${res.status}`);
234
+ const data = await res.json() as { valid: boolean; feedback?: string };
235
+ const retries = task.guardrail_retries + 1;
236
+
237
+ if (data.valid) {
238
+ db.prepare(
239
+ "UPDATE tasks SET status = 'completed', guardrail_retries = ?, updated_at = datetime('now') WHERE id = ?"
240
+ ).run(retries, task.id);
241
+ db.prepare(
242
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, 'completed', 'Guardrail passed')"
243
+ ).run(task.id, task.agent);
244
+ emitEvent("tasks", "task.completed", { agent: task.agent, project: task.project, title: task.title, task_id: task.id });
245
+ } else {
246
+ const fb = data.feedback ?? "Guardrail rejected output";
247
+ db.prepare(
248
+ "UPDATE tasks SET status = 'active', feedback = ?, guardrail_retries = ?, updated_at = datetime('now') WHERE id = ?"
249
+ ).run(fb, retries, task.id);
250
+ db.prepare(
251
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, 'active', ?)"
252
+ ).run(task.id, task.agent, `Guardrail failed (attempt ${retries}): ${fb}`);
253
+ }
254
+ })
255
+ .catch((err: any) => {
256
+ db.prepare(
257
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, ?, ?)"
258
+ ).run(task.id, task.agent, task.status, `Guardrail error: ${err.message}`);
259
+ });
260
+ }
261
+
262
+ export function submitFeedback(db: Database, id: number, feedback: string): Task | undefined {
263
+ const existing = getTask(db, id);
264
+ if (!existing) return undefined;
265
+
266
+ const result = db.prepare(
267
+ "UPDATE tasks SET feedback = ?, status = 'active', updated_at = datetime('now') WHERE id = ? RETURNING *"
268
+ ).get(feedback, id) as Task;
269
+
270
+ db.prepare(
271
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, 'active', ?)"
272
+ ).run(id, existing.agent, `Human feedback: ${feedback.slice(0, 100)}`);
273
+
274
+ emitEvent("tasks", "task.feedback", { agent: existing.agent, project: existing.project, title: existing.title, task_id: id, feedback });
275
+
276
+ return result;
277
+ }
278
+
279
+ export function getFeed(
280
+ db: Database,
281
+ limit: number = 50,
282
+ offset: number = 0
283
+ ): (TaskUpdate & { project: string; title: string })[] {
284
+ return db.prepare(`
285
+ SELECT tu.*, COALESCE(t.project, 'deleted') as project, COALESCE(t.title, 'deleted') as title
286
+ FROM task_updates tu
287
+ LEFT JOIN tasks t ON tu.task_id = t.id
288
+ ORDER BY tu.created_at DESC, tu.id DESC
289
+ LIMIT ? OFFSET ?
290
+ `).all(limit, offset) as (TaskUpdate & { project: string; title: string })[];
291
+ }
292
+
293
+ // ============================================================================
294
+ // PATH CLAIMS
295
+ // ============================================================================
296
+
297
+ const DEFAULT_CLAIM_TTL = 1800; // 30 minutes
298
+
299
+ export function createClaims(
300
+ db: Database,
301
+ taskId: number,
302
+ agent: string,
303
+ project: string,
304
+ paths: string[],
305
+ ttlSeconds: number = DEFAULT_CLAIM_TTL,
306
+ ): PathClaim[] {
307
+ if (paths.length === 0) return [];
308
+ const claims: PathClaim[] = [];
309
+ const run = db.transaction(() => {
310
+ for (const path of paths) {
311
+ const claim = db.prepare(
312
+ `INSERT INTO path_claims (task_id, agent, project, path, expires_at)
313
+ VALUES (?, ?, ?, ?, datetime('now', '+' || ? || ' seconds')) RETURNING *`
314
+ ).get(taskId, agent, project, path, ttlSeconds) as PathClaim;
315
+ claims.push(claim);
316
+ }
317
+ });
318
+ run();
319
+ emitEvent("claims", "claim.created", { task_id: taskId, agent, project, paths });
320
+ return claims;
321
+ }
322
+
323
+ export function releaseClaims(db: Database, taskId: number): number {
324
+ const result = db.prepare(
325
+ "UPDATE path_claims SET released = 1 WHERE task_id = ? AND released = 0"
326
+ ).run(taskId);
327
+ if (result.changes > 0) {
328
+ emitEvent("claims", "claim.released", { task_id: taskId, count: result.changes });
329
+ }
330
+ return result.changes;
331
+ }
332
+
333
+ export function releaseClaimsByPath(db: Database, taskId: number, paths: string[]): number {
334
+ let total = 0;
335
+ for (const path of paths) {
336
+ total += db.prepare(
337
+ "UPDATE path_claims SET released = 1 WHERE task_id = ? AND path = ? AND released = 0"
338
+ ).run(taskId, path).changes;
339
+ }
340
+ return total;
341
+ }
342
+
343
+ export function checkConflicts(
344
+ db: Database,
345
+ project: string,
346
+ paths: string[],
347
+ excludeTaskId?: number,
348
+ ): PathConflict[] {
349
+ const conflicts: PathConflict[] = [];
350
+ for (const path of paths) {
351
+ const rows = db.prepare(`
352
+ SELECT id AS claim_id, task_id, agent, path AS claimed_path
353
+ FROM path_claims
354
+ WHERE project = ? AND released = 0 AND expires_at > datetime('now')
355
+ AND (path || '/' LIKE ? || '%' OR ? LIKE path || '%')
356
+ ${excludeTaskId != null ? "AND task_id != ?" : ""}
357
+ `).all(
358
+ ...(excludeTaskId != null
359
+ ? [project, path, path, excludeTaskId]
360
+ : [project, path, path])
361
+ ) as { claim_id: number; task_id: number; agent: string; claimed_path: string }[];
362
+
363
+ for (const row of rows) {
364
+ conflicts.push({ ...row, path });
365
+ }
366
+ }
367
+ return conflicts;
368
+ }
369
+
370
+ export function getClaimsForTask(db: Database, taskId: number): PathClaim[] {
371
+ return db.prepare(
372
+ "SELECT * FROM path_claims WHERE task_id = ? AND released = 0 AND expires_at > datetime('now') ORDER BY path"
373
+ ).all(taskId) as PathClaim[];
374
+ }
375
+
376
+ export function getClaimsForProject(db: Database, project: string): PathClaim[] {
377
+ return db.prepare(
378
+ "SELECT * FROM path_claims WHERE project = ? AND released = 0 AND expires_at > datetime('now') ORDER BY path"
379
+ ).all(project) as PathClaim[];
380
+ }
381
+
382
+ export function refreshClaimExpiry(db: Database, taskId: number, ttlSeconds: number = DEFAULT_CLAIM_TTL): number {
383
+ return db.prepare(
384
+ "UPDATE path_claims SET expires_at = datetime('now', '+' || ? || ' seconds') WHERE task_id = ? AND released = 0"
385
+ ).run(ttlSeconds, taskId).changes;
386
+ }
387
+
388
+ // ============================================================================
389
+ // HEARTBEAT & STALE DETECTION
390
+ // ============================================================================
391
+
392
+ export function recordHeartbeat(db: Database, taskId: number): Task | undefined {
393
+ const existing = getTask(db, taskId);
394
+ if (!existing) return undefined;
395
+
396
+ const result = db.prepare(
397
+ "UPDATE tasks SET last_heartbeat = datetime('now'), updated_at = datetime('now') WHERE id = ? RETURNING *"
398
+ ).get(taskId) as Task;
399
+
400
+ // Refresh claim expiry on heartbeat
401
+ refreshClaimExpiry(db, taskId, existing.heartbeat_interval * 2);
402
+
403
+ return result;
404
+ }
405
+
406
+ export function markStaleTasks(db: Database, graceMultiplier: number = 2): Task[] {
407
+ const staleTasks = db.prepare(`
408
+ SELECT * FROM tasks
409
+ WHERE status IN ('active', 'paused')
410
+ AND last_heartbeat IS NOT NULL
411
+ AND last_heartbeat < datetime('now', '-' || (heartbeat_interval * ?) || ' seconds')
412
+ `).all(graceMultiplier) as Task[];
413
+
414
+ for (const task of staleTasks) {
415
+ db.prepare(
416
+ "UPDATE tasks SET status = 'stale', updated_at = datetime('now') WHERE id = ?"
417
+ ).run(task.id);
418
+ db.prepare(
419
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, 'stale', 'Heartbeat timeout')"
420
+ ).run(task.id, task.agent);
421
+ releaseClaims(db, task.id);
422
+ }
423
+
424
+ return staleTasks;
425
+ }
426
+
427
+ // ============================================================================
428
+ // TASK DEPENDENCIES
429
+ // ============================================================================
430
+
431
+ export function hasCircularDependency(db: Database, taskId: number, targetId: number): boolean {
432
+ // BFS from targetId: if we can reach taskId, adding taskId->targetId would be circular
433
+ const visited = new Set<number>();
434
+ const queue = [targetId];
435
+ while (queue.length > 0) {
436
+ const current = queue.shift()!;
437
+ if (current === taskId) return true;
438
+ if (visited.has(current)) continue;
439
+ visited.add(current);
440
+ const deps = db.prepare(
441
+ "SELECT depends_on FROM task_dependencies WHERE task_id = ?"
442
+ ).all(current) as { depends_on: number }[];
443
+ for (const dep of deps) {
444
+ queue.push(dep.depends_on);
445
+ }
446
+ }
447
+ return false;
448
+ }
449
+
450
+ export function addDependencies(db: Database, taskId: number, dependsOn: number[]): void {
451
+ const run = db.transaction(() => {
452
+ for (const depId of dependsOn) {
453
+ if (depId === taskId) throw new Error("Task cannot depend on itself");
454
+ if (hasCircularDependency(db, taskId, depId)) {
455
+ throw new Error(`Circular dependency: ${taskId} -> ${depId} creates a cycle`);
456
+ }
457
+ db.prepare(
458
+ "INSERT OR IGNORE INTO task_dependencies (task_id, depends_on) VALUES (?, ?)"
459
+ ).run(taskId, depId);
460
+ }
461
+ });
462
+ run();
463
+ }
464
+
465
+ export function removeDependency(db: Database, taskId: number, dependsOn: number): boolean {
466
+ return db.prepare(
467
+ "DELETE FROM task_dependencies WHERE task_id = ? AND depends_on = ?"
468
+ ).run(taskId, dependsOn).changes > 0;
469
+ }
470
+
471
+ export function getDependencies(
472
+ db: Database,
473
+ taskId: number,
474
+ ): { depends_on: number; status: string; title: string; agent: string }[] {
475
+ return db.prepare(`
476
+ SELECT td.depends_on, t.status, t.title, t.agent
477
+ FROM task_dependencies td
478
+ JOIN tasks t ON td.depends_on = t.id
479
+ WHERE td.task_id = ?
480
+ ORDER BY td.depends_on
481
+ `).all(taskId) as { depends_on: number; status: string; title: string; agent: string }[];
482
+ }
483
+
484
+ export function getDependents(db: Database, taskId: number): number[] {
485
+ return (db.prepare(
486
+ "SELECT task_id FROM task_dependencies WHERE depends_on = ?"
487
+ ).all(taskId) as { task_id: number }[]).map(r => r.task_id);
488
+ }
489
+
490
+ export function checkAndUnblock(db: Database, completedTaskId: number): Task[] {
491
+ const dependents = getDependents(db, completedTaskId);
492
+ const unblocked: Task[] = [];
493
+
494
+ for (const depTaskId of dependents) {
495
+ const deps = getDependencies(db, depTaskId);
496
+ const allMet = deps.every(d => d.status === "completed");
497
+ if (allMet) {
498
+ const task = db.prepare(
499
+ "UPDATE tasks SET status = 'active', updated_at = datetime('now') WHERE id = ? AND status = 'blocked' RETURNING *"
500
+ ).get(depTaskId) as Task | undefined;
501
+ if (task) {
502
+ db.prepare(
503
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, 'active', 'Dependencies met, auto-unblocked')"
504
+ ).run(task.id, task.agent);
505
+ unblocked.push(task);
506
+ }
507
+ }
508
+ }
509
+
510
+ return unblocked;
511
+ }
512
+
513
+ // ============================================================================
514
+ // WORK QUEUE
515
+ // ============================================================================
516
+
517
+ export function enqueueTask(
518
+ db: Database,
519
+ data: { project: string; title: string; summary?: string; expected_output?: string; condition?: string },
520
+ ): Task {
521
+ const result = db.prepare(
522
+ `INSERT INTO tasks (agent, project, title, summary, expected_output, status, assigned)
523
+ VALUES ('unassigned', ?, ?, ?, ?, 'queued', 0) RETURNING *`
524
+ ).get(
525
+ data.project, data.title, data.summary ?? null, data.expected_output ?? null,
526
+ ) as Task;
527
+
528
+ db.prepare(
529
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, 'admin', 'queued', ?)"
530
+ ).run(result.id, data.summary ?? null);
531
+
532
+ emitEvent("tasks", "task.queued", { project: result.project, title: result.title, task_id: result.id });
533
+ return result;
534
+ }
535
+
536
+ export function claimNextTask(db: Database, agent: string, project?: string): Task | undefined {
537
+ const run = db.transaction(() => {
538
+ let query = "SELECT * FROM tasks WHERE status = 'queued' AND assigned = 0";
539
+ const params: (string | number)[] = [];
540
+ if (project) { query += " AND project = ?"; params.push(project); }
541
+ query += " ORDER BY created_at ASC LIMIT 1";
542
+
543
+ const task = db.prepare(query).get(...params) as Task | undefined;
544
+ if (!task) return undefined;
545
+
546
+ const result = db.prepare(
547
+ `UPDATE tasks SET agent = ?, assigned = 1, status = 'active',
548
+ last_heartbeat = datetime('now'), updated_at = datetime('now')
549
+ WHERE id = ? RETURNING *`
550
+ ).get(agent, task.id) as Task;
551
+
552
+ db.prepare(
553
+ "INSERT INTO task_updates (task_id, agent, status, summary) VALUES (?, ?, 'active', 'Claimed from queue')"
554
+ ).run(result.id, agent);
555
+
556
+ return result;
557
+ });
558
+
559
+ const result = run();
560
+ if (result) {
561
+ emitEvent("tasks", "task.claimed", { agent, project: result.project, title: result.title, task_id: result.id });
562
+ }
563
+ return result;
564
+ }
565
+
566
+ // ============================================================================
567
+ // AGENT KEY MANAGEMENT
568
+ // ============================================================================
569
+
570
+ export function lookupAgentKey(db: Database, keyHash: string): AgentKey | undefined {
571
+ const key = db.prepare(
572
+ "SELECT * FROM agent_keys WHERE key_hash = ? AND revoked = 0"
573
+ ).get(keyHash) as AgentKey | undefined;
574
+ if (key) {
575
+ db.prepare("UPDATE agent_keys SET last_used_at = datetime('now') WHERE id = ?").run(key.id);
576
+ }
577
+ return key;
578
+ }
579
+
580
+ export function createAgentKey(db: Database, agent: string, keyHash: string, keyPrefix: string): AgentKey {
581
+ return db.prepare(
582
+ "INSERT INTO agent_keys (agent, key_hash, key_prefix) VALUES (?, ?, ?) RETURNING *"
583
+ ).get(agent, keyHash, keyPrefix) as AgentKey;
584
+ }
585
+
586
+ export function listAgentKeys(db: Database): Omit<AgentKey, "key_hash">[] {
587
+ return db.prepare(
588
+ "SELECT id, agent, key_prefix, created_at, last_used_at, revoked FROM agent_keys ORDER BY created_at DESC"
589
+ ).all() as Omit<AgentKey, "key_hash">[];
590
+ }
591
+
592
+ export function revokeAgentKey(db: Database, id: number): boolean {
593
+ return db.prepare("UPDATE agent_keys SET revoked = 1 WHERE id = ?").run(id).changes > 0;
594
+ }
595
+
596
+ export function pruneTaskUpdates(db: Database, maxRows: number, maxAgeDays: number) {
597
+ if (maxAgeDays > 0) {
598
+ db.prepare("DELETE FROM task_updates WHERE created_at < datetime('now', ?)").run(`-${maxAgeDays} days`);
599
+ }
600
+ if (maxRows > 0) {
601
+ db.prepare(`
602
+ DELETE FROM task_updates WHERE id IN (
603
+ SELECT id FROM (SELECT id FROM task_updates ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)
604
+ )
605
+ `).run(maxRows);
606
+ }
607
+ }
608
+
609
+ // ============================================================================
610
+ // STATS
611
+ // ============================================================================
612
+
613
+ export function getChiasmStats(db: Database) {
614
+ const total = (db.prepare("SELECT COUNT(*) as count FROM tasks").get() as any).count;
615
+ const by_status = db.prepare(
616
+ "SELECT status, COUNT(*) as count FROM tasks GROUP BY status ORDER BY count DESC",
617
+ ).all();
618
+ const by_project = db.prepare(
619
+ "SELECT project, COUNT(*) as count FROM tasks GROUP BY project ORDER BY count DESC",
620
+ ).all();
621
+ const active_claims = (db.prepare(
622
+ "SELECT COUNT(*) as count FROM path_claims WHERE released = 0 AND expires_at > datetime('now')",
623
+ ).get() as any).count;
624
+ return { total, by_status, by_project, active_claims };
625
+ }