@viraatdas/rudder 1.0.74 → 1.1.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.
Files changed (58) hide show
  1. package/dist/auth.d.ts +2 -0
  2. package/dist/auth.js +22 -1
  3. package/dist/auth.js.map +1 -1
  4. package/dist/backends.js +88 -1
  5. package/dist/backends.js.map +1 -1
  6. package/dist/board/board.css +1 -0
  7. package/dist/board/board.js +2 -0
  8. package/dist/board/daemon.d.ts +21 -0
  9. package/dist/board/daemon.js +838 -0
  10. package/dist/board/daemon.js.map +1 -0
  11. package/dist/brain.d.ts +9 -0
  12. package/dist/brain.js +32 -5
  13. package/dist/brain.js.map +1 -1
  14. package/dist/bus.d.ts +9 -0
  15. package/dist/bus.js +23 -0
  16. package/dist/bus.js.map +1 -0
  17. package/dist/daemon.d.ts +21 -0
  18. package/dist/daemon.js +141 -0
  19. package/dist/daemon.js.map +1 -0
  20. package/dist/git.d.ts +5 -16
  21. package/dist/git.js +43 -50
  22. package/dist/git.js.map +1 -1
  23. package/dist/goal.d.ts +30 -0
  24. package/dist/goal.js +75 -0
  25. package/dist/goal.js.map +1 -0
  26. package/dist/graph.d.ts +56 -0
  27. package/dist/graph.js +213 -0
  28. package/dist/graph.js.map +1 -0
  29. package/dist/jj.d.ts +121 -0
  30. package/dist/jj.js +524 -0
  31. package/dist/jj.js.map +1 -0
  32. package/dist/main.js +171 -6
  33. package/dist/main.js.map +1 -1
  34. package/dist/native/rudder-native +0 -0
  35. package/dist/planner.d.ts +27 -0
  36. package/dist/planner.js +540 -0
  37. package/dist/planner.js.map +1 -0
  38. package/dist/run-manager.d.ts +7 -0
  39. package/dist/run-manager.js +98 -38
  40. package/dist/run-manager.js.map +1 -1
  41. package/dist/scheduler.d.ts +124 -0
  42. package/dist/scheduler.js +849 -0
  43. package/dist/scheduler.js.map +1 -0
  44. package/dist/state.d.ts +16 -1
  45. package/dist/state.js +101 -0
  46. package/dist/state.js.map +1 -1
  47. package/dist/surfaces.d.ts +23 -0
  48. package/dist/surfaces.js +196 -0
  49. package/dist/surfaces.js.map +1 -0
  50. package/dist/task-summary.d.ts +18 -0
  51. package/dist/task-summary.js +132 -0
  52. package/dist/task-summary.js.map +1 -1
  53. package/dist/types.d.ts +198 -1
  54. package/dist/types.js +1 -1
  55. package/dist/types.js.map +1 -1
  56. package/dist/util.js +1 -0
  57. package/dist/util.js.map +1 -1
  58. package/package.json +9 -2
@@ -0,0 +1,838 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import http from "node:http";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { findProjectBySlug, loadProjects, loadRunRecord, outputPath, projectStateDir, runsDir } from "../state.js";
8
+ import { mergeJjRunIntoCurrentWorkspace } from "../jj.js";
9
+ import { hardParents, readGraph, softParents, updateGraph } from "../graph.js";
10
+ import { stopRun } from "../run-manager.js";
11
+ import { mergeNodeIntoIntegration, reconcileInjection } from "../scheduler.js";
12
+ import { nowIso } from "../util.js";
13
+ // dist/board/daemon.js sits next to dist/board/board.{js,css}, so the prebuilt
14
+ // SPA bundle resolves relative to this module's own URL.
15
+ const BOARD_JS_PATH = fileURLToPath(new URL("./board.js", import.meta.url));
16
+ const BOARD_CSS_PATH = fileURLToPath(new URL("./board.css", import.meta.url));
17
+ // One Set of SSE clients per slug, plus one fs.watch per watched runs dir.
18
+ const sseClients = new Map();
19
+ const watchers = new Map();
20
+ // A second watch per slug on the tracked DECISIONS.md at the repo root: it lives
21
+ // outside .rudder, so a sibling decision (or `rudder remember`) must trigger a
22
+ // re-broadcast (memory.updated SSE) the same way a run/graph change does.
23
+ const decisionsWatchers = new Map();
24
+ const watchTimers = new Map();
25
+ export async function startBoardDaemon(opts) {
26
+ // Subscribe the SSE broadcaster to the shared bus: node.*/schedule.*/merge.*
27
+ // events re-broadcast a fresh snapshot to every connected client (simplest
28
+ // correct projection; the SPA always rebuilds from the snapshot on connect).
29
+ let unsubscribe;
30
+ if (opts.bus) {
31
+ unsubscribe = opts.bus.subscribe(() => {
32
+ for (const slug of sseClients.keys()) {
33
+ void rebroadcastForSlug(slug);
34
+ }
35
+ });
36
+ }
37
+ const server = http.createServer((req, res) => {
38
+ handleRequest(req, res, opts.bus).catch((error) => {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ if (!res.headersSent) {
41
+ sendJson(res, 500, { error: message });
42
+ }
43
+ else {
44
+ try {
45
+ res.end();
46
+ }
47
+ catch {
48
+ // ignore
49
+ }
50
+ }
51
+ });
52
+ });
53
+ await new Promise((resolve, reject) => {
54
+ server.once("error", reject);
55
+ server.listen(opts.port, "127.0.0.1", () => {
56
+ server.removeListener("error", reject);
57
+ resolve();
58
+ });
59
+ });
60
+ const address = server.address();
61
+ const port = typeof address === "object" && address ? address.port : opts.port;
62
+ const url = `http://127.0.0.1:${port}`;
63
+ const close = async () => {
64
+ unsubscribe?.();
65
+ for (const [, timer] of watchTimers) {
66
+ clearTimeout(timer);
67
+ }
68
+ watchTimers.clear();
69
+ for (const [, watcher] of watchers) {
70
+ watcher.close();
71
+ }
72
+ watchers.clear();
73
+ for (const [, watcher] of decisionsWatchers) {
74
+ watcher.close();
75
+ }
76
+ decisionsWatchers.clear();
77
+ for (const [, clients] of sseClients) {
78
+ for (const client of clients) {
79
+ try {
80
+ client.res.end();
81
+ }
82
+ catch {
83
+ // ignore
84
+ }
85
+ }
86
+ }
87
+ sseClients.clear();
88
+ await new Promise((resolve) => server.close(() => resolve()));
89
+ };
90
+ // Determine this repo's slug for the open-browser convenience landing.
91
+ let slug = "";
92
+ try {
93
+ const projects = await loadProjects();
94
+ const resolved = path.resolve(opts.repoRoot);
95
+ slug = projects.find((entry) => path.resolve(entry.repoRoot) === resolved)?.slug ?? "";
96
+ }
97
+ catch {
98
+ slug = "";
99
+ }
100
+ if (opts.open) {
101
+ openBrowser(slug ? `${url}/rudder/${slug}` : `${url}/rudder`);
102
+ }
103
+ return { port, url, close };
104
+ }
105
+ async function handleRequest(req, res, bus) {
106
+ const url = new URL(req.url || "/", "http://127.0.0.1");
107
+ const pathname = decodeURIComponent(url.pathname);
108
+ const method = (req.method || "GET").toUpperCase();
109
+ // Static SPA bundle.
110
+ if (method === "GET" && pathname === "/board.js") {
111
+ await sendStatic(res, BOARD_JS_PATH, "text/javascript; charset=utf-8");
112
+ return;
113
+ }
114
+ if (method === "GET" && pathname === "/board.css") {
115
+ await sendStatic(res, BOARD_CSS_PATH, "text/css; charset=utf-8");
116
+ return;
117
+ }
118
+ // API routes: /api/projects[/:slug/...]
119
+ if (pathname === "/api/projects" && method === "GET") {
120
+ await handleProjectsList(res);
121
+ return;
122
+ }
123
+ const apiMatch = pathname.match(/^\/api\/projects\/([^/]+)(\/.*)?$/);
124
+ if (apiMatch) {
125
+ const slug = apiMatch[1] ?? "";
126
+ const rest = apiMatch[2] ?? "";
127
+ await handleProjectApi(req, res, method, slug, rest, url, bus);
128
+ return;
129
+ }
130
+ // SPA shell for the index and per-project routes.
131
+ if (method === "GET" && (pathname === "/" || pathname === "/rudder")) {
132
+ sendHtml(res, renderShell(""));
133
+ return;
134
+ }
135
+ const slugMatch = pathname.match(/^\/rudder\/([^/]+)\/?$/);
136
+ if (method === "GET" && slugMatch) {
137
+ sendHtml(res, renderShell(slugMatch[1] ?? ""));
138
+ return;
139
+ }
140
+ sendJson(res, 404, { error: "not found" });
141
+ }
142
+ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
143
+ const project = await findProjectBySlug(slug);
144
+ if (!project) {
145
+ sendJson(res, 404, { error: `unknown project: ${slug}` });
146
+ return;
147
+ }
148
+ if (rest === "/state" && method === "GET") {
149
+ const snapshot = await buildSnapshot(project);
150
+ sendJson(res, 200, snapshot);
151
+ return;
152
+ }
153
+ if (rest === "/events" && method === "GET") {
154
+ await handleSse(req, res, project);
155
+ return;
156
+ }
157
+ if (rest === "/tasks" && method === "POST") {
158
+ const body = await readJsonBody(req);
159
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
160
+ if (!prompt) {
161
+ sendJson(res, 400, { error: "missing prompt" });
162
+ return;
163
+ }
164
+ if (!bus) {
165
+ sendJson(res, 503, { error: "scheduler not available" });
166
+ return;
167
+ }
168
+ // The injection chokepoint: a typed task becomes a NEW node reconciled
169
+ // against the frontier (never blindly appended), then the scheduler takes
170
+ // over. Routes through reconcileInjection rather than plain startRun.
171
+ const title = typeof body.title === "string" ? body.title.trim() : undefined;
172
+ const result = await inRepo(project.repoRoot, () => reconcileInjection(project.repoRoot, { prompt, ...(title ? { title } : {}) }, bus));
173
+ sendJson(res, 200, { nodeId: result.nodeId, nodeIds: [result.nodeId] });
174
+ return;
175
+ }
176
+ const logMatch = rest.match(/^\/tasks\/([^/]+)\/log$/);
177
+ if (logMatch && method === "GET") {
178
+ const id = logMatch[1] ?? "";
179
+ const tail = Number.parseInt(url.searchParams.get("tail") ?? "200", 10);
180
+ const text = await readLogTail(project.repoRoot, id, Number.isFinite(tail) ? tail : 200);
181
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
182
+ res.end(text);
183
+ return;
184
+ }
185
+ // Approve a node in review: mark reviewState "approved" and merge it into the
186
+ // integration trunk via the scheduler (the daemon-owned jj merge path).
187
+ const approveMatch = rest.match(/^\/tasks\/([^/]+)\/approve$/);
188
+ if (approveMatch && method === "POST") {
189
+ const id = approveMatch[1] ?? "";
190
+ if (!bus) {
191
+ sendJson(res, 503, { error: "scheduler not available" });
192
+ return;
193
+ }
194
+ const result = await inRepo(project.repoRoot, async () => {
195
+ let target;
196
+ await updateGraph(project.repoRoot, (graph) => {
197
+ const node = graph.nodes[id] ?? Object.values(graph.nodes).find((candidate) => candidate.runId === id);
198
+ if (node) {
199
+ node.reviewState = "approved";
200
+ node.updatedAt = nowIso();
201
+ target = node;
202
+ }
203
+ return graph;
204
+ });
205
+ if (!target) {
206
+ return { ok: false };
207
+ }
208
+ await mergeNodeIntoIntegration(project.repoRoot, target, bus);
209
+ return { ok: true, nodeId: target.id };
210
+ });
211
+ if (!result.ok) {
212
+ sendJson(res, 404, { error: `unknown node: ${id}` });
213
+ return;
214
+ }
215
+ sendJson(res, 200, { ok: true, nodeId: result.nodeId });
216
+ return;
217
+ }
218
+ const mergeMatch = rest.match(/^\/tasks\/([^/]+)\/merge$/);
219
+ if (mergeMatch && method === "POST") {
220
+ const id = mergeMatch[1] ?? "";
221
+ // A graph node id routes through the daemon's integration merge (same path
222
+ // as approve). A bare run id keeps the legacy run-merge for unmanaged runs.
223
+ if (bus) {
224
+ const graph = await readGraph(project.repoRoot);
225
+ const node = graph.nodes[id] ?? Object.values(graph.nodes).find((candidate) => candidate.runId === id);
226
+ if (node) {
227
+ await inRepo(project.repoRoot, () => mergeNodeIntoIntegration(project.repoRoot, node, bus));
228
+ const fresh = await readGraph(project.repoRoot);
229
+ const refreshed = fresh.nodes[node.id];
230
+ sendJson(res, 200, {
231
+ status: refreshed?.status ?? "merged",
232
+ conflictedFiles: [],
233
+ });
234
+ return;
235
+ }
236
+ }
237
+ const run = await loadRunRecord(project.repoRoot, id);
238
+ if (!run) {
239
+ sendJson(res, 404, { error: `unknown run: ${id}` });
240
+ return;
241
+ }
242
+ const merged = await inRepo(project.repoRoot, () => mergeJjRunIntoCurrentWorkspace(run));
243
+ sendJson(res, 200, {
244
+ status: merged.merge?.status ?? merged.status,
245
+ conflictedFiles: merged.merge?.conflictedFiles ?? [],
246
+ });
247
+ return;
248
+ }
249
+ const cancelMatch = rest.match(/^\/tasks\/([^/]+)\/cancel$/);
250
+ if (cancelMatch && method === "POST") {
251
+ const id = cancelMatch[1] ?? "";
252
+ await inRepo(project.repoRoot, () => stopRun(id, { silent: true }));
253
+ sendJson(res, 200, { ok: true });
254
+ return;
255
+ }
256
+ sendJson(res, 404, { error: "not found" });
257
+ }
258
+ async function handleProjectsList(res) {
259
+ const projects = await loadProjects();
260
+ const summaries = await Promise.all(projects.map((entry) => buildProjectSummary(entry)));
261
+ sendJson(res, 200, { projects: summaries });
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // SSE: emit a full snapshot on connect, then deltas driven by fs.watch.
265
+ // ---------------------------------------------------------------------------
266
+ async function handleSse(req, res, project) {
267
+ res.writeHead(200, {
268
+ "content-type": "text/event-stream",
269
+ "cache-control": "no-cache, no-transform",
270
+ connection: "keep-alive",
271
+ });
272
+ res.write(": connected\n\n");
273
+ const client = { res };
274
+ let set = sseClients.get(project.slug);
275
+ if (!set) {
276
+ set = new Set();
277
+ sseClients.set(project.slug, set);
278
+ }
279
+ set.add(client);
280
+ ensureWatcher(project);
281
+ // Full snapshot on connect.
282
+ const snapshot = await buildSnapshot(project);
283
+ writeSseFrame(res, "snapshot", snapshot);
284
+ const ping = setInterval(() => {
285
+ try {
286
+ res.write(": ping\n\n");
287
+ }
288
+ catch {
289
+ // ignore
290
+ }
291
+ }, 15000);
292
+ ping.unref?.();
293
+ const cleanup = () => {
294
+ clearInterval(ping);
295
+ const clients = sseClients.get(project.slug);
296
+ if (clients) {
297
+ clients.delete(client);
298
+ if (clients.size === 0) {
299
+ sseClients.delete(project.slug);
300
+ const watcher = watchers.get(project.slug);
301
+ if (watcher) {
302
+ watcher.close();
303
+ watchers.delete(project.slug);
304
+ }
305
+ const decisionsWatcher = decisionsWatchers.get(project.slug);
306
+ if (decisionsWatcher) {
307
+ decisionsWatcher.close();
308
+ decisionsWatchers.delete(project.slug);
309
+ }
310
+ const timer = watchTimers.get(project.slug);
311
+ if (timer) {
312
+ clearTimeout(timer);
313
+ watchTimers.delete(project.slug);
314
+ }
315
+ }
316
+ }
317
+ };
318
+ req.on("close", cleanup);
319
+ res.on("close", cleanup);
320
+ }
321
+ function ensureWatcher(project) {
322
+ if (watchers.has(project.slug)) {
323
+ return;
324
+ }
325
+ // Watch the whole .rudder dir recursively: this covers both runs/<id>/*.json
326
+ // (worker-owned execution state) and graph.json (daemon-owned DAG topology),
327
+ // so plan/edge changes broadcast a fresh snapshot just like run changes.
328
+ const dir = projectStateDir(project.repoRoot);
329
+ const runsPath = runsDir(project.repoRoot);
330
+ try {
331
+ fs.mkdirSync(runsPath, { recursive: true });
332
+ }
333
+ catch {
334
+ // ignore
335
+ }
336
+ let watcher;
337
+ try {
338
+ watcher = fs.watch(dir, { recursive: true }, () => scheduleBroadcast(project));
339
+ }
340
+ catch {
341
+ try {
342
+ // Non-recursive fallback: watch the runs dir (most active) since some
343
+ // platforms reject recursive watches.
344
+ watcher = fs.watch(runsPath, () => scheduleBroadcast(project));
345
+ }
346
+ catch {
347
+ return;
348
+ }
349
+ }
350
+ watcher.on("error", () => undefined);
351
+ watchers.set(project.slug, watcher);
352
+ ensureDecisionsWatcher(project);
353
+ }
354
+ // Watch the repo-root DECISIONS.md (created on first render if absent) so an
355
+ // agent or `rudder remember` appending a decision fires a re-broadcast and the
356
+ // memory.updated SSE, exactly like a run/graph change inside .rudder.
357
+ function ensureDecisionsWatcher(project) {
358
+ if (decisionsWatchers.has(project.slug)) {
359
+ return;
360
+ }
361
+ const decisionsPath = path.join(project.repoRoot, "DECISIONS.md");
362
+ try {
363
+ // Watching a possibly-absent file throws; watch the repo root and filter for
364
+ // DECISIONS.md so the surface is covered even before it is first created.
365
+ const watcher = fs.watch(project.repoRoot, (_event, filename) => {
366
+ if (!filename || filename === "DECISIONS.md") {
367
+ scheduleBroadcast(project);
368
+ }
369
+ });
370
+ watcher.on("error", () => undefined);
371
+ decisionsWatchers.set(project.slug, watcher);
372
+ }
373
+ catch {
374
+ // Fall back to a direct file watch if the dir watch is rejected.
375
+ try {
376
+ const watcher = fs.watch(decisionsPath, () => scheduleBroadcast(project));
377
+ watcher.on("error", () => undefined);
378
+ decisionsWatchers.set(project.slug, watcher);
379
+ }
380
+ catch {
381
+ // ignore: the .rudder watch + bus still cover most updates.
382
+ }
383
+ }
384
+ }
385
+ function scheduleBroadcast(project) {
386
+ const existing = watchTimers.get(project.slug);
387
+ if (existing) {
388
+ clearTimeout(existing);
389
+ }
390
+ const timer = setTimeout(() => {
391
+ watchTimers.delete(project.slug);
392
+ void broadcastSnapshot(project);
393
+ }, 50);
394
+ timer.unref?.();
395
+ watchTimers.set(project.slug, timer);
396
+ }
397
+ // Phase 2 keeps deltas simple: recompute the snapshot and diff node ids so the
398
+ // SPA receives node.added/node.updated/node.removed frames, mirroring the
399
+ // cloud broadcast pattern (one payload, fan out to every client for the slug).
400
+ const lastNodes = new Map();
401
+ const lastEdges = new Map();
402
+ const lastMemory = new Map();
403
+ function edgeKey(edge) {
404
+ return `${edge.from}->${edge.to}:${edge.kind}`;
405
+ }
406
+ // Bus-driven re-broadcast: resolve the slug to a project and emit deltas. Used
407
+ // when a scheduler/merge event fires so connected clients refresh without
408
+ // waiting for the fs.watch debounce.
409
+ async function rebroadcastForSlug(slug) {
410
+ const clients = sseClients.get(slug);
411
+ if (!clients || clients.size === 0) {
412
+ return;
413
+ }
414
+ const project = await findProjectBySlug(slug);
415
+ if (project) {
416
+ await broadcastSnapshot(project);
417
+ }
418
+ }
419
+ async function broadcastSnapshot(project) {
420
+ const clients = sseClients.get(project.slug);
421
+ if (!clients || clients.size === 0) {
422
+ return;
423
+ }
424
+ const snapshot = await buildSnapshot(project);
425
+ const previous = lastNodes.get(project.slug) ?? new Map();
426
+ const current = new Map();
427
+ for (const node of snapshot.nodes) {
428
+ current.set(node.id, node);
429
+ }
430
+ const frames = [];
431
+ for (const [id, node] of current) {
432
+ const prior = previous.get(id);
433
+ if (!prior) {
434
+ frames.push({ event: "node.added", data: node });
435
+ }
436
+ else if (JSON.stringify(prior) !== JSON.stringify(node)) {
437
+ frames.push({ event: "node.updated", data: node });
438
+ }
439
+ }
440
+ for (const [id] of previous) {
441
+ if (!current.has(id)) {
442
+ frames.push({ event: "node.removed", data: { id } });
443
+ }
444
+ }
445
+ lastNodes.set(project.slug, current);
446
+ // Edge deltas from graph.json: edge.added / edge.removed for the Nest view.
447
+ const previousEdges = lastEdges.get(project.slug) ?? new Map();
448
+ const currentEdges = new Map();
449
+ for (const edge of snapshot.edges) {
450
+ currentEdges.set(edgeKey(edge), edge);
451
+ }
452
+ for (const [key, edge] of currentEdges) {
453
+ if (!previousEdges.has(key)) {
454
+ frames.push({ event: "edge.added", data: edge });
455
+ }
456
+ }
457
+ for (const [key, edge] of previousEdges) {
458
+ if (!currentEdges.has(key)) {
459
+ frames.push({ event: "edge.removed", data: { from: edge.from, to: edge.to } });
460
+ }
461
+ }
462
+ lastEdges.set(project.slug, currentEdges);
463
+ const memoryKey = JSON.stringify(snapshot.memory);
464
+ if (lastMemory.get(project.slug) !== memoryKey) {
465
+ lastMemory.set(project.slug, memoryKey);
466
+ frames.push({ event: "memory.updated", data: { memory: snapshot.memory } });
467
+ }
468
+ if (frames.length === 0) {
469
+ return;
470
+ }
471
+ for (const client of clients) {
472
+ for (const frame of frames) {
473
+ writeSseFrame(client.res, frame.event, frame.data);
474
+ }
475
+ }
476
+ }
477
+ function writeSseFrame(res, event, data) {
478
+ try {
479
+ res.write(`event: ${event}\n`);
480
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
481
+ }
482
+ catch {
483
+ // ignore broken pipe
484
+ }
485
+ }
486
+ // ---------------------------------------------------------------------------
487
+ // Projection: RunRecord -> BoardNode / BoardSnapshot / ProjectSummary.
488
+ // ---------------------------------------------------------------------------
489
+ export function columnForStatus(status) {
490
+ switch (status) {
491
+ case "created":
492
+ return "todo";
493
+ case "running":
494
+ case "steering":
495
+ case "verifying":
496
+ return "running";
497
+ case "completed":
498
+ case "merge-conflict":
499
+ return "review";
500
+ case "merged":
501
+ case "failed":
502
+ case "cancelled":
503
+ return "done";
504
+ default:
505
+ return "todo";
506
+ }
507
+ }
508
+ function projectRunToNode(run, lastLine) {
509
+ return {
510
+ id: run.id,
511
+ title: run.taskSummary || run.task,
512
+ status: run.status,
513
+ column: columnForStatus(run.status),
514
+ blocked: false,
515
+ backend: run.backend,
516
+ model: run.model,
517
+ effort: run.effort,
518
+ lastLine,
519
+ tokens: null,
520
+ deps: { hard: [], soft: [] },
521
+ createdAt: run.createdAt,
522
+ updatedAt: run.updatedAt,
523
+ worktree: run.worktree
524
+ ? { path: run.worktree.path, workspaceName: run.worktree.workspaceName }
525
+ : null,
526
+ merge: run.merge ?? null,
527
+ };
528
+ }
529
+ function columnForNodeStatus(status) {
530
+ switch (status) {
531
+ case "planned":
532
+ case "ready":
533
+ return "todo";
534
+ case "running":
535
+ return "running";
536
+ case "review":
537
+ return "review";
538
+ case "merged":
539
+ case "failed":
540
+ return "done";
541
+ case "blocked":
542
+ return "todo";
543
+ default:
544
+ return "todo";
545
+ }
546
+ }
547
+ // Project a planner TaskNode (one that has not been scheduled into a run yet)
548
+ // into a BoardNode. Run-derived nodes stay authoritative for scheduled nodes;
549
+ // these fill in the not-yet-scheduled DAG. Deps come from incoming edges.
550
+ function projectTaskNodeToBoardNode(node, deps) {
551
+ return {
552
+ id: node.id,
553
+ title: node.title,
554
+ // RunStatus and NodeStatus share running/merged/failed; the SPA reads
555
+ // `column` for layout, so the broad status string is sufficient here.
556
+ status: node.status,
557
+ column: columnForNodeStatus(node.status),
558
+ blocked: node.status === "blocked",
559
+ backend: node.backend,
560
+ model: node.model,
561
+ effort: node.effort,
562
+ lastLine: node.lastLine ?? null,
563
+ tokens: node.tokens ?? null,
564
+ deps,
565
+ createdAt: node.createdAt,
566
+ updatedAt: node.updatedAt,
567
+ worktree: node.worktree ? { path: node.worktree.path, workspaceName: node.worktree.workspaceName } : null,
568
+ merge: null,
569
+ };
570
+ }
571
+ // Build {from-node-id -> {hard,soft}} maps so each BoardNode carries the ids of
572
+ // its incoming parents, and the flat BoardEdge list for the Nest/DAG view.
573
+ function projectGraphEdges(graph) {
574
+ const edges = [];
575
+ const depsByNode = new Map();
576
+ for (const id of Object.keys(graph.nodes)) {
577
+ depsByNode.set(id, { hard: hardParents(graph, id), soft: softParents(graph, id) });
578
+ }
579
+ for (const edge of Object.values(graph.edges)) {
580
+ edges.push({ from: edge.from, to: edge.to, kind: edge.type });
581
+ }
582
+ return { edges, depsByNode };
583
+ }
584
+ async function buildSnapshot(project) {
585
+ const runs = await listProjectRuns(project.repoRoot);
586
+ const graph = await readGraph(project.repoRoot);
587
+ const { edges, depsByNode } = projectGraphEdges(graph);
588
+ const nodes = [];
589
+ // Run-derived nodes are authoritative. Track which graph nodes they cover so
590
+ // we do not double-list a node that has already been scheduled into a run.
591
+ const coveredNodeIds = new Set();
592
+ for (const node of Object.values(graph.nodes)) {
593
+ if (node.runId) {
594
+ coveredNodeIds.add(node.runId);
595
+ }
596
+ }
597
+ for (const run of runs) {
598
+ const lastLine = await lastNonEmptyLine(project.repoRoot, run.id);
599
+ const boardNode = projectRunToNode(run, lastLine);
600
+ // If a graph node points at this run, carry its DAG deps onto the run node.
601
+ const graphNode = Object.values(graph.nodes).find((candidate) => candidate.runId === run.id);
602
+ if (graphNode) {
603
+ boardNode.deps = depsByNode.get(graphNode.id) ?? boardNode.deps;
604
+ }
605
+ nodes.push(boardNode);
606
+ }
607
+ // Planner nodes that have not been scheduled yet (no runId) fill in the DAG.
608
+ for (const node of Object.values(graph.nodes)) {
609
+ if (node.runId) {
610
+ continue;
611
+ }
612
+ nodes.push(projectTaskNodeToBoardNode(node, depsByNode.get(node.id) ?? { hard: [], soft: [] }));
613
+ }
614
+ const memory = await loadMemory(project.repoRoot);
615
+ return {
616
+ slug: project.slug,
617
+ name: project.name,
618
+ generatedAt: new Date().toISOString(),
619
+ nodes,
620
+ edges,
621
+ gates: [],
622
+ memory,
623
+ };
624
+ }
625
+ async function buildProjectSummary(project) {
626
+ const runs = await listProjectRuns(project.repoRoot);
627
+ const counts = { todo: 0, running: 0, review: 0, done: 0, blocked: 0, failed: 0 };
628
+ let lastActivityAt = "";
629
+ for (const run of runs) {
630
+ counts[columnForStatus(run.status)] += 1;
631
+ if (run.status === "failed") {
632
+ counts.failed += 1;
633
+ }
634
+ const stamp = run.updatedAt || run.createdAt;
635
+ if (stamp && stamp > lastActivityAt) {
636
+ lastActivityAt = stamp;
637
+ }
638
+ }
639
+ return {
640
+ slug: project.slug,
641
+ name: project.name,
642
+ repoRoot: project.repoRoot,
643
+ counts,
644
+ lastActivityAt: lastActivityAt || new Date(0).toISOString(),
645
+ };
646
+ }
647
+ async function listProjectRuns(repoRoot) {
648
+ const dir = runsDir(repoRoot);
649
+ let entries;
650
+ try {
651
+ entries = await fsp.readdir(dir, { withFileTypes: true });
652
+ }
653
+ catch {
654
+ return [];
655
+ }
656
+ const runs = await Promise.all(entries
657
+ .filter((entry) => entry.isDirectory())
658
+ .map((entry) => loadRunRecord(repoRoot, entry.name)));
659
+ return runs
660
+ .filter((run) => Boolean(run))
661
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
662
+ }
663
+ async function lastNonEmptyLine(repoRoot, runId) {
664
+ const text = await readOutput(repoRoot, runId);
665
+ if (!text) {
666
+ return null;
667
+ }
668
+ const lines = text.split(/\r?\n/);
669
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
670
+ const trimmed = lines[i]?.trim();
671
+ if (trimmed) {
672
+ return trimmed.length > 500 ? trimmed.slice(0, 500) : trimmed;
673
+ }
674
+ }
675
+ return null;
676
+ }
677
+ async function readOutput(repoRoot, runId) {
678
+ try {
679
+ return await fsp.readFile(outputPath(repoRoot, runId), "utf8");
680
+ }
681
+ catch {
682
+ return "";
683
+ }
684
+ }
685
+ async function readLogTail(repoRoot, runId, tail) {
686
+ const text = await readOutput(repoRoot, runId);
687
+ if (!text) {
688
+ return "";
689
+ }
690
+ const lines = text.split(/\r?\n/);
691
+ // A trailing newline yields an empty final element; drop it so tail=N counts
692
+ // real lines rather than spending a slot on the blank line.
693
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
694
+ lines.pop();
695
+ }
696
+ const count = Math.max(1, tail);
697
+ return lines.slice(-count).join("\n");
698
+ }
699
+ /**
700
+ * Memory view: parse DECISIONS.md (repo root) into bullet entries. Each
701
+ * top-level "-" / "*" bullet becomes one MemoryEntry; absent file yields [].
702
+ * A trailing "(owner: X, <iso>)" suffix (written by `rudder remember` and the
703
+ * agent contract) is extracted into owner/ts; plain bullets just get text.
704
+ */
705
+ export function parseDecisions(raw) {
706
+ const entries = [];
707
+ for (const line of raw.split(/\r?\n/)) {
708
+ const match = line.match(/^\s*[-*]\s+(.*)$/);
709
+ const body = match?.[1]?.trim();
710
+ if (!body) {
711
+ continue;
712
+ }
713
+ entries.push(parseDecisionBullet(body));
714
+ }
715
+ return entries;
716
+ }
717
+ // Parse one bullet body. The optional "(owner: X, <iso>)" suffix is stripped off
718
+ // the text and split into owner + ts. A bullet may carry just "(owner: X)" or a
719
+ // bare "(<iso>)"; anything unrecognized stays part of the text.
720
+ function parseDecisionBullet(body) {
721
+ const suffix = body.match(/\s*\(owner:\s*([^,)]+?)(?:,\s*([^)]+?))?\)\s*$/i);
722
+ if (suffix) {
723
+ const text = body.slice(0, suffix.index).trim();
724
+ const owner = suffix[1]?.trim();
725
+ const ts = suffix[2]?.trim();
726
+ return {
727
+ text: text || body,
728
+ ...(owner ? { owner } : {}),
729
+ ...(ts ? { ts } : {}),
730
+ };
731
+ }
732
+ return { text: body };
733
+ }
734
+ async function loadMemory(repoRoot) {
735
+ let raw;
736
+ try {
737
+ raw = await fsp.readFile(path.join(repoRoot, "DECISIONS.md"), "utf8");
738
+ }
739
+ catch {
740
+ return [];
741
+ }
742
+ return parseDecisions(raw);
743
+ }
744
+ // ---------------------------------------------------------------------------
745
+ // HTML shell. The SPA owns all CSS; this carries no inline styles.
746
+ // ---------------------------------------------------------------------------
747
+ function renderShell(slug) {
748
+ const slugJson = JSON.stringify(slug ?? "");
749
+ return `<!doctype html>
750
+ <html lang="en">
751
+ <head>
752
+ <meta charset="utf-8" />
753
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
754
+ <title>rudder</title>
755
+ <link rel="stylesheet" href="/board.css" />
756
+ </head>
757
+ <body>
758
+ <div id="app"></div>
759
+ <script>window.__RUDDER_SLUG__ = ${slugJson}</script>
760
+ <script type="module" src="/board.js"></script>
761
+ </body>
762
+ </html>
763
+ `;
764
+ }
765
+ // ---------------------------------------------------------------------------
766
+ // Helpers.
767
+ // ---------------------------------------------------------------------------
768
+ /**
769
+ * Run a callback with the process cwd pinned to repoRoot, so run-manager and jj
770
+ * functions that resolve the repo from cwd act on the right project. The board
771
+ * serializes mutating routes implicitly via this chdir swap.
772
+ */
773
+ let repoChdirLock = Promise.resolve();
774
+ function inRepo(repoRoot, fn) {
775
+ const run = repoChdirLock.then(async () => {
776
+ const previous = process.cwd();
777
+ process.chdir(repoRoot);
778
+ try {
779
+ return await fn();
780
+ }
781
+ finally {
782
+ try {
783
+ process.chdir(previous);
784
+ }
785
+ catch {
786
+ // ignore
787
+ }
788
+ }
789
+ });
790
+ repoChdirLock = run.then(() => undefined, () => undefined);
791
+ return run;
792
+ }
793
+ async function readJsonBody(req) {
794
+ const chunks = [];
795
+ for await (const chunk of req) {
796
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
797
+ }
798
+ if (chunks.length === 0) {
799
+ return {};
800
+ }
801
+ try {
802
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
803
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
804
+ ? parsed
805
+ : {};
806
+ }
807
+ catch {
808
+ return {};
809
+ }
810
+ }
811
+ async function sendStatic(res, filePath, contentType) {
812
+ try {
813
+ const data = await fsp.readFile(filePath);
814
+ res.writeHead(200, { "content-type": contentType });
815
+ res.end(data);
816
+ }
817
+ catch {
818
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
819
+ res.end("bundle not built");
820
+ }
821
+ }
822
+ function sendJson(res, status, body) {
823
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
824
+ res.end(JSON.stringify(body));
825
+ }
826
+ function sendHtml(res, body, status = 200) {
827
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
828
+ res.end(body);
829
+ }
830
+ function openBrowser(url) {
831
+ const platform = process.platform;
832
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
833
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
834
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
835
+ child.on("error", () => undefined);
836
+ child.unref();
837
+ }
838
+ //# sourceMappingURL=daemon.js.map