feinai 0.5.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/src/server.ts ADDED
@@ -0,0 +1,749 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, basename, resolve, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { openDb, findDbPath, type DbInstance } from "./db";
5
+ import {
6
+ listTasks,
7
+ getTask,
8
+ addTask,
9
+ takeTask,
10
+ doneTask,
11
+ failTask,
12
+ blockTask,
13
+ releaseTask,
14
+ reopenTask,
15
+ type Task,
16
+ } from "./tasks";
17
+ import {
18
+ listSpecs,
19
+ getSpec,
20
+ addSpec,
21
+ startSpec,
22
+ doneSpec,
23
+ archiveSpec,
24
+ unarchiveSpec,
25
+ deleteSpec,
26
+ setSpecContent,
27
+ addPlan,
28
+ getLatestPlan,
29
+ listPlans,
30
+ type Spec,
31
+ } from "./specs";
32
+ import { dashboardHtml } from "./dashboard";
33
+ import { inspectWorktree } from "./worktree-status";
34
+ import { listAgentProcesses } from "./agents-status";
35
+
36
+ interface Stats {
37
+ pending: number;
38
+ in_progress: number;
39
+ completed: number;
40
+ failed: number;
41
+ specs: number;
42
+ plans: number;
43
+ }
44
+
45
+ interface SpecWithExtras extends Spec {
46
+ task_count: number;
47
+ task_summary: { pending: number; in_progress: number; completed: number };
48
+ latest_plan_version: number | null;
49
+ }
50
+
51
+ interface EventRow {
52
+ id: number;
53
+ entity_type: string;
54
+ entity_id: string;
55
+ event_type: string;
56
+ actor: string | null;
57
+ payload: string | null;
58
+ created_at: string;
59
+ }
60
+
61
+ function getStats(db: DbInstance): Stats {
62
+ const taskCounts = db
63
+ .prepare(
64
+ `SELECT
65
+ SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS pending,
66
+ SUM(CASE WHEN status='in_progress' THEN 1 ELSE 0 END) AS in_progress,
67
+ SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) AS completed,
68
+ SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) AS failed
69
+ FROM tasks`,
70
+ )
71
+ .get() as Record<string, number | null>;
72
+ const specs = (db.prepare(`SELECT COUNT(*) AS n FROM specs`).get() as { n: number }).n;
73
+ const plans = (db.prepare(`SELECT COUNT(*) AS n FROM plans`).get() as { n: number }).n;
74
+ return {
75
+ pending: taskCounts.pending ?? 0,
76
+ in_progress: taskCounts.in_progress ?? 0,
77
+ completed: taskCounts.completed ?? 0,
78
+ failed: taskCounts.failed ?? 0,
79
+ specs,
80
+ plans,
81
+ };
82
+ }
83
+
84
+ function listSpecsWithExtras(db: DbInstance): SpecWithExtras[] {
85
+ const specs = listSpecs(db);
86
+ return specs.map((spec) => {
87
+ const taskCounts = db
88
+ .prepare(
89
+ `SELECT
90
+ SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS pending,
91
+ SUM(CASE WHEN status='in_progress' THEN 1 ELSE 0 END) AS in_progress,
92
+ SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) AS completed,
93
+ COUNT(*) AS total
94
+ FROM tasks WHERE spec_id = ?`,
95
+ )
96
+ .get(spec.id) as {
97
+ pending: number | null;
98
+ in_progress: number | null;
99
+ completed: number | null;
100
+ total: number;
101
+ };
102
+ const planRow = db
103
+ .prepare(`SELECT MAX(version) AS v FROM plans WHERE spec_id = ?`)
104
+ .get(spec.id) as { v: number | null };
105
+ return {
106
+ ...spec,
107
+ task_count: taskCounts.total ?? 0,
108
+ task_summary: {
109
+ pending: taskCounts.pending ?? 0,
110
+ in_progress: taskCounts.in_progress ?? 0,
111
+ completed: taskCounts.completed ?? 0,
112
+ },
113
+ latest_plan_version: planRow.v ?? null,
114
+ };
115
+ });
116
+ }
117
+
118
+ function listEvents(db: DbInstance, limit: number = 50, sinceId: number = 0): EventRow[] {
119
+ return db
120
+ .prepare(
121
+ `SELECT id, entity_type, entity_id, event_type, actor, payload, created_at
122
+ FROM events
123
+ WHERE id > ?
124
+ ORDER BY id DESC LIMIT ?`,
125
+ )
126
+ .all(sinceId, limit) as EventRow[];
127
+ }
128
+
129
+ function getMaxEventId(db: DbInstance): number {
130
+ const row = db.prepare(`SELECT COALESCE(MAX(id), 0) AS id FROM events`).get() as { id: number };
131
+ return row.id;
132
+ }
133
+
134
+ interface SearchHit {
135
+ type: "spec" | "task";
136
+ id: string;
137
+ title: string;
138
+ status: string;
139
+ match_field: string;
140
+ }
141
+
142
+ // In-memory request log (non-persistent, session-only)
143
+ interface RequestLogEntry {
144
+ id: number;
145
+ method: string;
146
+ path: string;
147
+ actor: string;
148
+ source: string;
149
+ timestamp: string;
150
+ }
151
+
152
+ const requestLog: RequestLogEntry[] = [];
153
+ let nextRequestLogId = 1;
154
+ const MAX_REQUEST_LOG = 100;
155
+
156
+ function deriveSource(actor: string): "Dashboard" | "API" | "Terminal" {
157
+ if (actor === "dashboard") return "Dashboard";
158
+ if (actor.startsWith("api:")) return "API";
159
+ return "Terminal";
160
+ }
161
+
162
+ function logRequest(method: string, path: string, actor: string): void {
163
+ if (path === "/" || path === "/api/events/stream") return;
164
+
165
+ // Suppress dashboard's own periodic polling noise only.
166
+ // Everything else (agent reads, terminal commands, dashboard detail views) is logged.
167
+ const POLL_PATHS = ["/api/status", "/api/specs", "/api/tasks", "/api/events", "/api/agents"];
168
+ if (actor === "dashboard" && POLL_PATHS.includes(path)) return;
169
+
170
+ const source = deriveSource(actor);
171
+ requestLog.push({
172
+ id: nextRequestLogId++,
173
+ method,
174
+ path,
175
+ actor,
176
+ source,
177
+ timestamp: new Date().toISOString(),
178
+ });
179
+ if (requestLog.length > MAX_REQUEST_LOG) {
180
+ requestLog.splice(0, requestLog.length - MAX_REQUEST_LOG);
181
+ }
182
+ }
183
+
184
+ function search(db: DbInstance, query: string, limit: number = 20): SearchHit[] {
185
+ const pattern = `%${query.toLowerCase()}%`;
186
+ const specs = db
187
+ .prepare(
188
+ `SELECT id, title, status,
189
+ CASE
190
+ WHEN lower(title) LIKE ? THEN 'title'
191
+ WHEN lower(content) LIKE ? THEN 'content'
192
+ END AS match_field
193
+ FROM specs
194
+ WHERE lower(title) LIKE ? OR lower(content) LIKE ?
195
+ LIMIT ?`,
196
+ )
197
+ .all(pattern, pattern, pattern, pattern, limit) as Array<{
198
+ id: string;
199
+ title: string;
200
+ status: string;
201
+ match_field: string;
202
+ }>;
203
+
204
+ const tasks = db
205
+ .prepare(
206
+ `SELECT id, subject AS title, status,
207
+ CASE
208
+ WHEN lower(subject) LIKE ? THEN 'subject'
209
+ WHEN lower(description) LIKE ? THEN 'description'
210
+ END AS match_field
211
+ FROM tasks
212
+ WHERE lower(subject) LIKE ? OR lower(description) LIKE ?
213
+ LIMIT ?`,
214
+ )
215
+ .all(pattern, pattern, pattern, pattern, limit) as Array<{
216
+ id: string;
217
+ title: string;
218
+ status: string;
219
+ match_field: string;
220
+ }>;
221
+
222
+ return [
223
+ ...specs.map((s) => ({ type: "spec" as const, ...s })),
224
+ ...tasks.map((t) => ({ type: "task" as const, ...t })),
225
+ ];
226
+ }
227
+
228
+ function json(body: unknown, status: number = 200): Response {
229
+ return new Response(JSON.stringify(body, null, 2), {
230
+ status,
231
+ headers: {
232
+ "Content-Type": "application/json; charset=utf-8",
233
+ "Access-Control-Allow-Origin": "*",
234
+ },
235
+ });
236
+ }
237
+
238
+ function notFound(msg: string = "Not found"): Response {
239
+ return json({ error: msg }, 404);
240
+ }
241
+
242
+ function badRequest(msg: string): Response {
243
+ return json({ error: msg }, 400);
244
+ }
245
+
246
+ function serverError(err: unknown): Response {
247
+ const msg = err instanceof Error ? err.message : String(err);
248
+ return json({ error: msg }, 500);
249
+ }
250
+
251
+ /**
252
+ * Determine actor for an inbound HTTP mutation. Priority:
253
+ * 1. X-Tasca-Actor header (explicit)
254
+ * 2. derived from request: "dashboard:<host>" or "api:<user-agent>"
255
+ */
256
+ function actorFromRequest(req: Request): string {
257
+ // Bun's headers.get may not be case-insensitive for custom headers;
258
+ // iterate manually to be safe.
259
+ let explicit: string | undefined;
260
+ for (const [k, v] of req.headers.entries()) {
261
+ if (k.toLowerCase() === "x-tasca-actor") {
262
+ explicit = v;
263
+ break;
264
+ }
265
+ }
266
+ if (explicit) return explicit;
267
+
268
+ const ua = req.headers.get("user-agent") ?? "";
269
+ if (ua.includes("Mozilla")) return "dashboard";
270
+ return `api:${ua.split(/\s+/)[0] || "unknown"}`;
271
+ }
272
+
273
+ async function readJsonBody<T = unknown>(req: Request): Promise<T> {
274
+ try {
275
+ return (await req.json()) as T;
276
+ } catch {
277
+ throw new Error("Invalid JSON body");
278
+ }
279
+ }
280
+
281
+ function findRepoRoot(startDir: string = process.cwd()): string | null {
282
+ let current = resolve(startDir);
283
+ while (true) {
284
+ if (existsSync(join(current, ".git"))) return current;
285
+ const parent = dirname(current);
286
+ if (parent === current) return null;
287
+ current = parent;
288
+ }
289
+ }
290
+
291
+ let cachedDashboardVersion = "";
292
+ async function resolveDashboardVersion(): Promise<string> {
293
+ if (cachedDashboardVersion) return cachedDashboardVersion;
294
+ const root = findRepoRoot();
295
+ if (!root) return "";
296
+ try {
297
+ const countResult = await Bun.$`git log --oneline -- tools/tasca/src/dashboard.html | wc -l`.cwd(root).text();
298
+ const count = parseInt(countResult.trim(), 10);
299
+ const hashResult = await Bun.$`git log -1 --format=%h -- tools/tasca/src/dashboard.html`.cwd(root).text();
300
+ const hash = hashResult.trim();
301
+ cachedDashboardVersion = `dashboard-v${count} (${hash})`;
302
+ } catch {
303
+ cachedDashboardVersion = "";
304
+ }
305
+ return cachedDashboardVersion;
306
+ }
307
+
308
+ export interface ServerOptions {
309
+ port: number;
310
+ host?: string;
311
+ }
312
+
313
+ export function startServer(opts: ServerOptions): { url: string; stop: () => void } {
314
+ const server = Bun.serve({
315
+ port: opts.port,
316
+ hostname: opts.host ?? "127.0.0.1",
317
+ async fetch(req: Request): Promise<Response> {
318
+ const url = new URL(req.url);
319
+ const path = url.pathname;
320
+ const method = req.method;
321
+
322
+ // CORS preflight
323
+ if (method === "OPTIONS") {
324
+ return new Response(null, {
325
+ headers: {
326
+ "Access-Control-Allow-Origin": "*",
327
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, PATCH, OPTIONS",
328
+ "Access-Control-Allow-Headers": "Content-Type, X-Tasca-Actor",
329
+ },
330
+ });
331
+ }
332
+
333
+ // Static dashboard — inject cached version string
334
+ if (path === "/" || path === "/index.html") {
335
+ const version = await resolveDashboardVersion();
336
+ const html = version
337
+ ? (dashboardHtml as unknown as string).replace('<span id="dash-version">v—</span>', `<span id="dash-version">${version}</span>`)
338
+ : (dashboardHtml as unknown as string);
339
+ return new Response(html, {
340
+ headers: { "Content-Type": "text/html; charset=utf-8" },
341
+ });
342
+ }
343
+
344
+ // SSE stream — special-cased: keeps connection open, polls events table.
345
+ if (path === "/api/events/stream" && method === "GET") {
346
+ return handleSseStream(req);
347
+ }
348
+
349
+ // Determine actor and log the request (session-only, non-persistent)
350
+ const actor = actorFromRequest(req);
351
+ logRequest(method, path, actor);
352
+
353
+ // All other API routes open db lazily
354
+ let db: DbInstance;
355
+ try {
356
+ db = openDb();
357
+ } catch (err) {
358
+ return serverError(err);
359
+ }
360
+
361
+ try {
362
+ // ===== READ ENDPOINTS =====
363
+
364
+ if (path === "/api/status" && method === "GET") {
365
+ return json(getStats(db));
366
+ }
367
+
368
+ if (path === "/api/specs" && method === "GET") {
369
+ return json(listSpecsWithExtras(db));
370
+ }
371
+
372
+ const specMatch = path.match(/^\/api\/specs\/([^/]+)$/);
373
+ if (specMatch && method === "GET") {
374
+ const id = decodeURIComponent(specMatch[1]!);
375
+ const spec = getSpec(db, id);
376
+ if (!spec) return notFound(`Spec ${id} not found`);
377
+ const tasks = listTasks(db, { spec_id: id });
378
+ const plans = listPlans(db, id);
379
+ const latestPlan = getLatestPlan(db, id);
380
+ return json({ spec, tasks, plans, latest_plan: latestPlan });
381
+ }
382
+
383
+ const specContentMatch = path.match(/^\/api\/specs\/([^/]+)\/content$/);
384
+ if (specContentMatch && method === "GET") {
385
+ const id = decodeURIComponent(specContentMatch[1]!);
386
+ const spec = getSpec(db, id);
387
+ if (!spec) return notFound(`Spec ${id} not found`);
388
+ return new Response(spec.content ?? "", {
389
+ headers: {
390
+ "Content-Type": "text/markdown; charset=utf-8",
391
+ "Access-Control-Allow-Origin": "*",
392
+ },
393
+ });
394
+ }
395
+
396
+ const specPlanMatch = path.match(/^\/api\/specs\/([^/]+)\/plan$/);
397
+ if (specPlanMatch && method === "GET") {
398
+ const id = decodeURIComponent(specPlanMatch[1]!);
399
+ const plan = getLatestPlan(db, id);
400
+ if (!plan) return notFound(`No plan for spec ${id}`);
401
+ return new Response(plan.content, {
402
+ headers: {
403
+ "Content-Type": "text/markdown; charset=utf-8",
404
+ "Access-Control-Allow-Origin": "*",
405
+ },
406
+ });
407
+ }
408
+
409
+ if (path === "/api/tasks" && method === "GET") {
410
+ const q = url.searchParams;
411
+ const tasks = listTasks(db, {
412
+ status: (q.get("status") as Task["status"] | null) ?? undefined,
413
+ spec_id: q.get("spec") ?? undefined,
414
+ owner: q.get("owner") ?? undefined,
415
+ pending: q.get("pending") === "1" || q.get("pending") === "true",
416
+ });
417
+ return json(tasks);
418
+ }
419
+
420
+ const taskMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
421
+ if (taskMatch && method === "GET") {
422
+ const id = decodeURIComponent(taskMatch[1]!);
423
+ const task = getTask(db, id);
424
+ if (!task) return notFound(`Task ${id} not found`);
425
+ return json(task);
426
+ }
427
+
428
+ // GET /api/tasks/:id/worktree-status
429
+ const taskWorktreeMatch = path.match(/^\/api\/tasks\/([^/]+)\/worktree-status$/);
430
+ if (taskWorktreeMatch && method === "GET") {
431
+ const id = decodeURIComponent(taskWorktreeMatch[1]!);
432
+ const task = getTask(db, id);
433
+ if (!task) return notFound(`Task ${id} not found`);
434
+ const status = await inspectWorktree(task.worktree);
435
+ return json(status);
436
+ }
437
+
438
+ // GET /api/agents — running opencode processes with task matching
439
+ if (path === "/api/agents" && method === "GET") {
440
+ const agents = await listAgentProcesses();
441
+ return json(agents);
442
+ }
443
+
444
+ // GET /api/context — repo and tasca DB context info for dashboard
445
+ if (path === "/api/context" && method === "GET") {
446
+ const repoRoot = findRepoRoot();
447
+ const dbPath = findDbPath();
448
+ const home = homedir();
449
+ const tascaPath = dbPath?.startsWith(home)
450
+ ? `~${dbPath.slice(home.length)}`
451
+ : (dbPath ?? null);
452
+ return json({
453
+ repoRoot: repoRoot ?? null,
454
+ repoName: repoRoot ? basename(repoRoot) : null,
455
+ tascaPath,
456
+ });
457
+ }
458
+
459
+ if (path === "/api/events" && method === "GET") {
460
+ const limit = Number(url.searchParams.get("limit") ?? "50");
461
+ if (Number.isNaN(limit) || limit < 1 || limit > 1000)
462
+ return badRequest("limit must be between 1 and 1000");
463
+ return json(listEvents(db, limit));
464
+ }
465
+
466
+ if (path === "/api/search" && method === "GET") {
467
+ const q = url.searchParams.get("q") ?? "";
468
+ if (!q.trim()) return json([]);
469
+ return json(search(db, q.trim(), 20));
470
+ }
471
+
472
+ // ===== MUTATION ENDPOINTS =====
473
+
474
+ // POST /api/specs — create spec
475
+ if (path === "/api/specs" && method === "POST") {
476
+ const body = await readJsonBody<{
477
+ id?: string;
478
+ title?: string;
479
+ content?: string;
480
+ }>(req);
481
+ if (!body.id || !body.title)
482
+ return badRequest("Required: id, title");
483
+ const spec = addSpec(
484
+ db,
485
+ { id: body.id, title: body.title, content: body.content },
486
+ actor,
487
+ );
488
+ return json(spec, 201);
489
+ }
490
+
491
+ // POST /api/specs/:id/start
492
+ const specStartMatch = path.match(/^\/api\/specs\/([^/]+)\/start$/);
493
+ if (specStartMatch && method === "POST") {
494
+ const id = decodeURIComponent(specStartMatch[1]!);
495
+ return json(startSpec(db, id, actor));
496
+ }
497
+
498
+ // POST /api/specs/:id/done
499
+ const specDoneMatch = path.match(/^\/api\/specs\/([^/]+)\/done$/);
500
+ if (specDoneMatch && method === "POST") {
501
+ const id = decodeURIComponent(specDoneMatch[1]!);
502
+ const body = await readJsonBody<{ pr?: string; merged_date?: string }>(req);
503
+ return json(doneSpec(db, id, body, actor));
504
+ }
505
+
506
+ // POST /api/specs/:id/archive
507
+ const specArchiveMatch = path.match(/^\/api\/specs\/([^/]+)\/archive$/);
508
+ if (specArchiveMatch && method === "POST") {
509
+ const id = decodeURIComponent(specArchiveMatch[1]!);
510
+ return json(archiveSpec(db, id, actor));
511
+ }
512
+
513
+ // POST /api/specs/:id/unarchive
514
+ const specUnarchiveMatch = path.match(/^\/api\/specs\/([^/]+)\/unarchive$/);
515
+ if (specUnarchiveMatch && method === "POST") {
516
+ const id = decodeURIComponent(specUnarchiveMatch[1]!);
517
+ return json(unarchiveSpec(db, id, actor));
518
+ }
519
+
520
+ // DELETE /api/specs/:id
521
+ const specDeleteMatch = path.match(/^\/api\/specs\/([^/]+)$/);
522
+ if (specDeleteMatch && method === "DELETE") {
523
+ const id = decodeURIComponent(specDeleteMatch[1]!);
524
+ return json(deleteSpec(db, id, actor));
525
+ }
526
+
527
+ // POST /api/specs/:id/content — replace content
528
+ const specContentPostMatch = path.match(/^\/api\/specs\/([^/]+)\/content$/);
529
+ if (specContentPostMatch && method === "POST") {
530
+ const id = decodeURIComponent(specContentPostMatch[1]!);
531
+ const body = await readJsonBody<{ content?: string }>(req);
532
+ if (typeof body.content !== "string")
533
+ return badRequest("Required: content (string)");
534
+ return json(setSpecContent(db, id, body.content, actor));
535
+ }
536
+
537
+ // POST /api/specs/:id/plans — add new plan version
538
+ const specPlansMatch = path.match(/^\/api\/specs\/([^/]+)\/plans$/);
539
+ if (specPlansMatch && method === "POST") {
540
+ const id = decodeURIComponent(specPlansMatch[1]!);
541
+ const body = await readJsonBody<{ content?: string }>(req);
542
+ if (typeof body.content !== "string")
543
+ return badRequest("Required: content (string)");
544
+ return json(addPlan(db, id, body.content, actor), 201);
545
+ }
546
+
547
+ // POST /api/tasks — create task
548
+ if (path === "/api/tasks" && method === "POST") {
549
+ const body = await readJsonBody<{
550
+ id?: string;
551
+ subject?: string;
552
+ description?: string;
553
+ spec_id?: string;
554
+ packages?: string[];
555
+ quality_gates?: string[];
556
+ blocked_by?: string[];
557
+ }>(req);
558
+ if (!body.id || !body.subject)
559
+ return badRequest("Required: id, subject");
560
+ const task = addTask(db, {
561
+ id: body.id,
562
+ subject: body.subject,
563
+ description: body.description,
564
+ spec_id: body.spec_id,
565
+ packages: body.packages,
566
+ quality_gates: body.quality_gates,
567
+ blocked_by: body.blocked_by,
568
+ });
569
+ return json(task, 201);
570
+ }
571
+
572
+ // POST /api/tasks/:id/take
573
+ const taskTakeMatch = path.match(/^\/api\/tasks\/([^/]+)\/take$/);
574
+ if (taskTakeMatch && method === "POST") {
575
+ const id = decodeURIComponent(taskTakeMatch[1]!);
576
+ const body = await readJsonBody<{ owner?: string }>(req).catch(() => ({} as { owner?: string }));
577
+ const owner = body.owner ?? actor;
578
+ return json(takeTask(db, id, owner));
579
+ }
580
+
581
+ // POST /api/tasks/:id/done
582
+ const taskDoneMatch = path.match(/^\/api\/tasks\/([^/]+)\/done$/);
583
+ if (taskDoneMatch && method === "POST") {
584
+ const id = decodeURIComponent(taskDoneMatch[1]!);
585
+ const body = await readJsonBody<{ result?: string }>(req);
586
+ if (!body.result) return badRequest("Required: result");
587
+ return json(doneTask(db, id, body.result, actor));
588
+ }
589
+
590
+ // POST /api/tasks/:id/fail
591
+ const taskFailMatch = path.match(/^\/api\/tasks\/([^/]+)\/fail$/);
592
+ if (taskFailMatch && method === "POST") {
593
+ const id = decodeURIComponent(taskFailMatch[1]!);
594
+ const body = await readJsonBody<{ error?: string }>(req);
595
+ if (!body.error) return badRequest("Required: error");
596
+ return json(failTask(db, id, body.error, actor));
597
+ }
598
+
599
+ // POST /api/tasks/:id/block
600
+ const taskBlockMatch = path.match(/^\/api\/tasks\/([^/]+)\/block$/);
601
+ if (taskBlockMatch && method === "POST") {
602
+ const id = decodeURIComponent(taskBlockMatch[1]!);
603
+ const body = await readJsonBody<{ by?: string }>(req);
604
+ if (!body.by) return badRequest("Required: by");
605
+ return json(blockTask(db, id, body.by));
606
+ }
607
+
608
+ // POST /api/tasks/:id/release
609
+ const taskReleaseMatch = path.match(/^\/api\/tasks\/([^/]+)\/release$/);
610
+ if (taskReleaseMatch && method === "POST") {
611
+ const id = decodeURIComponent(taskReleaseMatch[1]!);
612
+ return json(releaseTask(db, id, actor));
613
+ }
614
+
615
+ // POST /api/tasks/:id/reopen
616
+ const taskReopenMatch = path.match(/^\/api\/tasks\/([^/]+)\/reopen$/);
617
+ if (taskReopenMatch && method === "POST") {
618
+ const id = decodeURIComponent(taskReopenMatch[1]!);
619
+ return json(reopenTask(db, id, actor));
620
+ }
621
+
622
+ return notFound();
623
+ } catch (err) {
624
+ return serverError(err);
625
+ } finally {
626
+ db.close();
627
+ }
628
+ },
629
+ });
630
+
631
+ return {
632
+ url: `http://${server.hostname}:${server.port}`,
633
+ stop: () => server.stop(),
634
+ };
635
+ }
636
+
637
+ /**
638
+ * SSE stream: server-side polls events table and pushes new events to client.
639
+ * Replaces client-side polling. Client connects once, server sends updates
640
+ * as they appear in the audit log.
641
+ */
642
+ function handleSseStream(req: Request): Response {
643
+ const encoder = new TextEncoder();
644
+ let lastEventId = 0;
645
+ let lastRequestLogId = 0;
646
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
647
+
648
+ // Initialize lastEventId from current max so we don't replay all history
649
+ try {
650
+ const db = openDb();
651
+ lastEventId = getMaxEventId(db);
652
+ db.close();
653
+ } catch {
654
+ // ignore; will retry in poll
655
+ }
656
+
657
+ const stream = new ReadableStream({
658
+ start(controller) {
659
+ const send = (eventType: string, data: unknown) => {
660
+ try {
661
+ const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
662
+ controller.enqueue(encoder.encode(payload));
663
+ } catch {
664
+ // controller closed
665
+ }
666
+ };
667
+
668
+ // Initial hello so the client knows the stream is up
669
+ send("hello", { since_event_id: lastEventId, at: new Date().toISOString() });
670
+
671
+ // Replay current in-memory request log to the new client
672
+ if (requestLog.length > 0) {
673
+ for (const reqEntry of requestLog) {
674
+ send("request", reqEntry);
675
+ lastRequestLogId = Math.max(lastRequestLogId, reqEntry.id);
676
+ }
677
+ }
678
+
679
+ pollTimer = setInterval(() => {
680
+ let hadData = false;
681
+ try {
682
+ const db = openDb();
683
+ const newEvents = db
684
+ .prepare(
685
+ `SELECT id, entity_type, entity_id, event_type, actor, payload, created_at
686
+ FROM events WHERE id > ? ORDER BY id ASC LIMIT 50`,
687
+ )
688
+ .all(lastEventId) as EventRow[];
689
+ db.close();
690
+
691
+ if (newEvents.length > 0) {
692
+ for (const ev of newEvents) {
693
+ send(`event:${ev.event_type}`, ev);
694
+ lastEventId = Math.max(lastEventId, ev.id);
695
+ }
696
+ hadData = true;
697
+ }
698
+ } catch {
699
+ // DB unavailable transiently; try again on next tick
700
+ }
701
+
702
+ // Push new in-memory request log entries
703
+ const newRequests = requestLog.filter((r) => r.id > lastRequestLogId);
704
+ if (newRequests.length > 0) {
705
+ for (const reqEntry of newRequests) {
706
+ send("request", reqEntry);
707
+ lastRequestLogId = reqEntry.id;
708
+ }
709
+ hadData = true;
710
+ }
711
+
712
+ if (hadData) {
713
+ // Also send a generic "refresh" hint so dashboards can refetch summaries
714
+ send("refresh", { last_event_id: lastEventId });
715
+ } else {
716
+ // Keep-alive comment to stop proxies from closing the connection
717
+ try {
718
+ controller.enqueue(encoder.encode(`: keep-alive\n\n`));
719
+ } catch {
720
+ // closed
721
+ }
722
+ }
723
+ }, 1500);
724
+
725
+ // Detect client disconnect
726
+ req.signal.addEventListener("abort", () => {
727
+ if (pollTimer) clearInterval(pollTimer);
728
+ try {
729
+ controller.close();
730
+ } catch {
731
+ // already closed
732
+ }
733
+ });
734
+ },
735
+ cancel() {
736
+ if (pollTimer) clearInterval(pollTimer);
737
+ },
738
+ });
739
+
740
+ return new Response(stream, {
741
+ headers: {
742
+ "Content-Type": "text/event-stream",
743
+ "Cache-Control": "no-cache, no-transform",
744
+ "Connection": "keep-alive",
745
+ "X-Accel-Buffering": "no",
746
+ "Access-Control-Allow-Origin": "*",
747
+ },
748
+ });
749
+ }