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/CHANGELOG.md +41 -0
- package/README.md +230 -0
- package/package.json +60 -0
- package/skills/feinai-dispatch/SKILL.md +233 -0
- package/skills/feinai-implement/SKILL.md +133 -0
- package/skills/feinai-sdd/SKILL.md +291 -0
- package/skills/feinai-write-spec/SKILL.md +178 -0
- package/skills/feinai-write-tasks/SKILL.md +183 -0
- package/src/agents-status.ts +26 -0
- package/src/cli.ts +885 -0
- package/src/dashboard.html +1701 -0
- package/src/dashboard.ts +3 -0
- package/src/db.ts +221 -0
- package/src/format.ts +166 -0
- package/src/opengit.sh +117 -0
- package/src/server.ts +749 -0
- package/src/specs.ts +289 -0
- package/src/sqlite-adapter.ts +130 -0
- package/src/tasks.ts +415 -0
- package/src/worktree-status.ts +97 -0
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
|
+
}
|