@viraatdas/rudder 2.10.13 → 2.10.16

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.
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import fsp from "node:fs/promises";
5
5
  import http from "node:http";
@@ -64,7 +64,7 @@ function denyMutation(req, res) {
64
64
  import { findProjectBySlug, loadProjects, loadRunRecord, outputPath, projectStateDir, runsDir } from "../state.js";
65
65
  import { mergeJjRunIntoCurrentWorkspace } from "../jj.js";
66
66
  import { hardParents, readGraph, softParents, updateGraph } from "../graph.js";
67
- import { stopRun } from "../run-manager.js";
67
+ import { continueRun, stopRun } from "../run-manager.js";
68
68
  import { mergeNodeIntoIntegration, reconcileInjection, withSchedulerLock } from "../scheduler.js";
69
69
  import { nowIso } from "../util.js";
70
70
  // dist/board/daemon.js sits next to dist/board/board.{js,css}, so the prebuilt
@@ -80,6 +80,7 @@ const watchers = new Map();
80
80
  const decisionsWatchers = new Map();
81
81
  const watchTimers = new Map();
82
82
  export async function startBoardDaemon(opts) {
83
+ const controlMode = opts.controlMode ?? "projector";
83
84
  // Subscribe the SSE broadcaster to the shared bus: node.*/schedule.*/merge.*
84
85
  // events re-broadcast a fresh snapshot to every connected client (simplest
85
86
  // correct projection; the SPA always rebuilds from the snapshot on connect).
@@ -92,7 +93,7 @@ export async function startBoardDaemon(opts) {
92
93
  });
93
94
  }
94
95
  const server = http.createServer((req, res) => {
95
- handleRequest(req, res, opts.bus).catch((error) => {
96
+ handleRequest(req, res, opts.bus, controlMode, opts.repoRoot).catch((error) => {
96
97
  const message = error instanceof Error ? error.message : String(error);
97
98
  if (!res.headersSent) {
98
99
  sendJson(res, 500, { error: message });
@@ -148,8 +149,7 @@ export async function startBoardDaemon(opts) {
148
149
  let slug = "";
149
150
  try {
150
151
  const projects = await loadProjects();
151
- const resolved = path.resolve(opts.repoRoot);
152
- slug = projects.find((entry) => path.resolve(entry.repoRoot) === resolved)?.slug ?? "";
152
+ slug = projects.find((entry) => sameRepoPath(entry.repoRoot, opts.repoRoot))?.slug ?? "";
153
153
  }
154
154
  catch {
155
155
  slug = "";
@@ -159,7 +159,7 @@ export async function startBoardDaemon(opts) {
159
159
  }
160
160
  return { port, url, close };
161
161
  }
162
- async function handleRequest(req, res, bus) {
162
+ async function handleRequest(req, res, bus, controlMode, ownerRepoRoot) {
163
163
  const url = new URL(req.url || "/", "http://127.0.0.1");
164
164
  const pathname = decodeURIComponent(url.pathname);
165
165
  const method = (req.method || "GET").toUpperCase();
@@ -187,22 +187,25 @@ async function handleRequest(req, res, bus) {
187
187
  if (apiMatch) {
188
188
  const slug = apiMatch[1] ?? "";
189
189
  const rest = apiMatch[2] ?? "";
190
- await handleProjectApi(req, res, method, slug, rest, url, bus);
190
+ await handleProjectApi(req, res, method, slug, rest, url, bus, controlMode, ownerRepoRoot);
191
191
  return;
192
192
  }
193
193
  // SPA shell for the index and per-project routes.
194
194
  if (method === "GET" && (pathname === "/" || pathname === "/rudder")) {
195
- sendHtml(res, renderShell(""));
195
+ sendHtml(res, renderShell("", controlMode, false));
196
196
  return;
197
197
  }
198
198
  const slugMatch = pathname.match(/^\/rudder\/([^/]+)\/?$/);
199
199
  if (method === "GET" && slugMatch) {
200
- sendHtml(res, renderShell(slugMatch[1] ?? ""));
200
+ const slug = slugMatch[1] ?? "";
201
+ const project = await findProjectBySlug(slug);
202
+ const canMutate = Boolean(project && sameRepoPath(project.repoRoot, ownerRepoRoot));
203
+ sendHtml(res, renderShell(slug, controlMode, canMutate));
201
204
  return;
202
205
  }
203
206
  sendJson(res, 404, { error: "not found" });
204
207
  }
205
- async function handleProjectApi(req, res, method, slug, rest, url, bus) {
208
+ async function handleProjectApi(req, res, method, slug, rest, url, bus, controlMode = "projector", ownerRepoRoot = "") {
206
209
  const project = await findProjectBySlug(slug);
207
210
  if (!project) {
208
211
  sendJson(res, 404, { error: `unknown project: ${slug}` });
@@ -213,6 +216,17 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
213
216
  if (method === "POST" && denyMutation(req, res)) {
214
217
  return;
215
218
  }
219
+ // One daemon owns exactly one repository's scheduler or native-projector
220
+ // channel. Other registered projects remain readable in the overview, but
221
+ // mutations here would route work to the wrong owner.
222
+ if (method === "POST" &&
223
+ ownerRepoRoot &&
224
+ !sameRepoPath(project.repoRoot, ownerRepoRoot)) {
225
+ sendJson(res, 409, {
226
+ error: "this board daemon is read-only for that project; open its own Rudder board to make changes",
227
+ });
228
+ return;
229
+ }
216
230
  if (rest === "/state" && method === "GET") {
217
231
  const snapshot = await buildSnapshot(project);
218
232
  sendJson(res, 200, snapshot);
@@ -222,6 +236,18 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
222
236
  await handleSse(req, res, project);
223
237
  return;
224
238
  }
239
+ const steerReceiptMatch = rest.match(/^\/steer-receipts\/([A-Za-z0-9-]+)$/);
240
+ if (steerReceiptMatch && method === "GET") {
241
+ const requestId = steerReceiptMatch[1] ?? "";
242
+ const receipt = await readSteerReceipt(project.repoRoot, requestId);
243
+ if (receipt) {
244
+ sendJson(res, receipt.status === "queued" ? 202 : 200, receipt);
245
+ }
246
+ else {
247
+ sendJson(res, 202, { requestId, status: "queued", mode: "queued" });
248
+ }
249
+ return;
250
+ }
225
251
  if (rest === "/tasks" && method === "POST") {
226
252
  const body = await readJsonBody(req);
227
253
  const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
@@ -229,23 +255,56 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
229
255
  sendJson(res, 400, { error: "missing prompt" });
230
256
  return;
231
257
  }
258
+ // In projector mode graph.json is a one-way mirror written by the native
259
+ // dashboard. Queue the request through its durable control inbox so the TUI
260
+ // handles it exactly like text entered in the task pane. Writing graph.json
261
+ // here would create a card the TUI never schedules.
262
+ if (controlMode === "projector") {
263
+ const queued = await writeSteerRequest(project.repoRoot, "conductor", prompt, "task", requestIdFromBody(body));
264
+ sendJson(res, queued.status === "queued" ? 202 : 200, { ...queued, nodeIds: [] });
265
+ return;
266
+ }
232
267
  if (!bus) {
233
268
  sendJson(res, 503, { error: "scheduler not available" });
234
269
  return;
235
270
  }
271
+ const requestId = requestIdFromBody(body);
272
+ const claimed = await claimSteerRequest(project.repoRoot, requestId, "conductor", prompt);
273
+ if (!claimed) {
274
+ const existing = await readSteerReceipt(project.repoRoot, requestId);
275
+ if (existing?.taskId) {
276
+ sendJson(res, 200, {
277
+ requestId,
278
+ status: existing.status,
279
+ nodeId: existing.taskId,
280
+ nodeIds: [existing.taskId],
281
+ });
282
+ }
283
+ else {
284
+ sendJson(res, 202, { requestId, status: "queued", nodeIds: [] });
285
+ }
286
+ return;
287
+ }
236
288
  // The injection chokepoint: a typed task becomes a NEW node reconciled
237
289
  // against the frontier (never blindly appended), then the scheduler takes
238
290
  // over. Routes through reconcileInjection rather than plain startRun.
239
291
  const title = typeof body.title === "string" ? body.title.trim() : undefined;
240
292
  const result = await inRepo(project.repoRoot, () => reconcileInjection(project.repoRoot, { prompt, ...(title ? { title } : {}) }, bus));
241
- sendJson(res, 200, { nodeId: result.nodeId, nodeIds: [result.nodeId] });
293
+ await writeSteerReceipt(project.repoRoot, {
294
+ requestId,
295
+ status: "accepted",
296
+ mode: "queued",
297
+ taskId: result.nodeId,
298
+ });
299
+ sendJson(res, 200, { requestId, nodeId: result.nodeId, nodeIds: [result.nodeId] });
242
300
  return;
243
301
  }
244
302
  const logMatch = rest.match(/^\/tasks\/([^/]+)\/log$/);
245
303
  if (logMatch && method === "GET") {
246
304
  const id = logMatch[1] ?? "";
247
305
  const tail = Number.parseInt(url.searchParams.get("tail") ?? "200", 10);
248
- const text = await readLogTail(project.repoRoot, id, Number.isFinite(tail) ? tail : 200);
306
+ const target = await resolveSteerTarget(project.repoRoot, id);
307
+ const text = await readLogTail(project.repoRoot, target?.run.id ?? id, Number.isFinite(tail) ? tail : 200);
249
308
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
250
309
  res.end(text);
251
310
  return;
@@ -255,6 +314,11 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
255
314
  const approveMatch = rest.match(/^\/tasks\/([^/]+)\/approve$/);
256
315
  if (approveMatch && method === "POST") {
257
316
  const id = approveMatch[1] ?? "";
317
+ if (controlMode === "projector") {
318
+ const queued = await writeSteerRequest(project.repoRoot, id, "merge", "merge");
319
+ sendJson(res, queued.status === "queued" ? 202 : 200, queued);
320
+ return;
321
+ }
258
322
  if (!bus) {
259
323
  sendJson(res, 503, { error: "scheduler not available" });
260
324
  return;
@@ -288,6 +352,14 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
288
352
  const mergeMatch = rest.match(/^\/tasks\/([^/]+)\/merge$/);
289
353
  if (mergeMatch && method === "POST") {
290
354
  const id = mergeMatch[1] ?? "";
355
+ if (controlMode === "projector") {
356
+ const queued = await writeSteerRequest(project.repoRoot, id, "merge", "merge");
357
+ sendJson(res, queued.status === "queued" ? 202 : 200, {
358
+ ...queued,
359
+ conflictedFiles: [],
360
+ });
361
+ return;
362
+ }
291
363
  // A graph node id routes through the daemon's integration merge (same path
292
364
  // as approve). A bare run id keeps the legacy run-merge for unmanaged runs.
293
365
  if (bus) {
@@ -324,7 +396,14 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
324
396
  const cancelMatch = rest.match(/^\/tasks\/([^/]+)\/cancel$/);
325
397
  if (cancelMatch && method === "POST") {
326
398
  const id = cancelMatch[1] ?? "";
327
- await inRepo(project.repoRoot, () => stopRun(id, { silent: true }));
399
+ if (controlMode === "projector") {
400
+ const queued = await writeSteerRequest(project.repoRoot, id, "cancel", "cancel");
401
+ sendJson(res, queued.status === "queued" ? 202 : 200, queued);
402
+ return;
403
+ }
404
+ const graph = await readGraph(project.repoRoot);
405
+ const node = graph.nodes[id] ?? Object.values(graph.nodes).find((candidate) => candidate.runId === id);
406
+ await inRepo(project.repoRoot, () => stopRun(node?.runId ?? id, { silent: true }));
328
407
  sendJson(res, 200, { ok: true });
329
408
  return;
330
409
  }
@@ -346,8 +425,54 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
346
425
  sendJson(res, 400, { error: "missing instruction" });
347
426
  return;
348
427
  }
349
- await writeSteerRequest(project.repoRoot, id, instruction);
350
- sendJson(res, 200, { ok: true, taskId: id });
428
+ if (instruction.length > 8_000) {
429
+ sendJson(res, 413, { error: "instruction is too long (maximum 8000 characters)" });
430
+ return;
431
+ }
432
+ if (controlMode === "scheduler") {
433
+ if (!bus) {
434
+ sendJson(res, 503, { error: "scheduler not available" });
435
+ return;
436
+ }
437
+ const requestId = requestIdFromBody(body);
438
+ const claimed = await claimSteerRequest(project.repoRoot, requestId, id, instruction);
439
+ if (!claimed) {
440
+ const existing = await readSteerReceipt(project.repoRoot, requestId);
441
+ sendJson(res, existing?.status === "queued" ? 202 : 200, existing ?? {
442
+ requestId,
443
+ status: "queued",
444
+ mode: "queued",
445
+ taskId: id,
446
+ });
447
+ return;
448
+ }
449
+ const result = await inRepo(project.repoRoot, () => withSchedulerLock(project.repoRoot, () => steerHeadlessRun(project.repoRoot, id, instruction, bus, requestId)));
450
+ if (!result.ok) {
451
+ await writeSteerReceipt(project.repoRoot, {
452
+ requestId,
453
+ status: "failed",
454
+ mode: "queued",
455
+ taskId: id,
456
+ error: result.error,
457
+ });
458
+ sendJson(res, result.status, { error: result.error });
459
+ return;
460
+ }
461
+ await writeSteerReceipt(project.repoRoot, result.receipt);
462
+ sendJson(res, 200, result.receipt);
463
+ return;
464
+ }
465
+ const target = await resolveSteerTarget(project.repoRoot, id);
466
+ if (!target) {
467
+ sendJson(res, 404, { error: `unknown task: ${id}` });
468
+ return;
469
+ }
470
+ if (!isSteerable(target.run.status, target.node?.status)) {
471
+ sendJson(res, 409, { error: `task is ${target.node?.status ?? target.run.status} and cannot be steered` });
472
+ return;
473
+ }
474
+ const queued = await writeSteerRequest(project.repoRoot, target.node?.id ?? id, instruction, "steer", requestIdFromBody(body));
475
+ sendJson(res, queued.status === "queued" ? 202 : 200, queued);
351
476
  return;
352
477
  }
353
478
  // Conductor-level steer: same inbox, target "conductor".
@@ -362,8 +487,18 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
362
487
  sendJson(res, 400, { error: "missing instruction" });
363
488
  return;
364
489
  }
365
- await writeSteerRequest(project.repoRoot, "conductor", instruction);
366
- sendJson(res, 200, { ok: true, taskId: "conductor" });
490
+ if (instruction.length > 8_000) {
491
+ sendJson(res, 413, { error: "instruction is too long (maximum 8000 characters)" });
492
+ return;
493
+ }
494
+ if (controlMode === "scheduler") {
495
+ sendJson(res, 409, {
496
+ error: "the standalone board has no live conductor; create a task or steer a running worker instead",
497
+ });
498
+ return;
499
+ }
500
+ const queued = await writeSteerRequest(project.repoRoot, "conductor", instruction, "steer", requestIdFromBody(body));
501
+ sendJson(res, queued.status === "queued" ? 202 : 200, queued);
367
502
  return;
368
503
  }
369
504
  sendJson(res, 404, { error: "not found" });
@@ -371,15 +506,200 @@ async function handleProjectApi(req, res, method, slug, rest, url, bus) {
371
506
  // Write one steer request into .rudder/steer/. Filename is timestamp-prefixed so
372
507
  // the native poller applies queued steers in order; the id is sanitized so a node
373
508
  // id never escapes the inbox dir.
374
- async function writeSteerRequest(repoRoot, taskId, instruction) {
509
+ async function writeSteerRequest(repoRoot, taskId, instruction, kind, requestedRequestId) {
375
510
  const dir = path.join(projectStateDir(repoRoot), "steer");
376
511
  await fsp.mkdir(dir, { recursive: true });
512
+ await pruneSteerReceipts(repoRoot).catch(() => undefined);
377
513
  const ts = Date.now();
514
+ const requestId = requestedRequestId ?? randomUUID();
515
+ const claimed = await claimSteerRequest(repoRoot, requestId, taskId, instruction);
516
+ if (!claimed) {
517
+ const existing = await readSteerReceipt(repoRoot, requestId);
518
+ return { ok: true, ...(existing ?? { requestId, status: "queued", mode: "queued", taskId }) };
519
+ }
378
520
  const safeId = (taskId || "conductor").replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64) || "conductor";
379
- const file = path.join(dir, `${ts}-${safeId}.json`);
380
- const tmp = `${file}.tmp`;
381
- await fsp.writeFile(tmp, JSON.stringify({ taskId, instruction, ts: new Date(ts).toISOString() }));
521
+ // hrtime + UUID prevents same-millisecond steers from overwriting each other;
522
+ // the zero-padded wall-clock prefix retains delivery order for the TUI poller.
523
+ const sequence = process.hrtime.bigint().toString().padStart(20, "0");
524
+ const file = path.join(dir, `${String(ts).padStart(13, "0")}-${sequence}-${safeId}-${requestId}.json`);
525
+ const tmp = `${file}.${process.pid}.tmp`;
526
+ await fsp.writeFile(tmp, JSON.stringify({ requestId, kind, taskId, instruction, ts: new Date(ts).toISOString() }));
382
527
  await fsp.rename(tmp, file);
528
+ return { ok: true, requestId, status: "queued", mode: "queued", taskId };
529
+ }
530
+ function requestIdFromBody(body) {
531
+ const value = typeof body.requestId === "string" ? body.requestId.trim() : "";
532
+ return /^[A-Za-z0-9-]{8,128}$/.test(value) ? value : randomUUID();
533
+ }
534
+ async function claimSteerRequest(repoRoot, requestId, taskId, instruction) {
535
+ const dir = path.join(projectStateDir(repoRoot), "steer-claims");
536
+ await fsp.mkdir(dir, { recursive: true });
537
+ try {
538
+ await fsp.writeFile(path.join(dir, `${requestId}.json`), JSON.stringify({ requestId, taskId, instruction, claimedAt: nowIso() }), { flag: "wx" });
539
+ return true;
540
+ }
541
+ catch (error) {
542
+ if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
543
+ return false;
544
+ }
545
+ throw error;
546
+ }
547
+ }
548
+ async function writeSteerReceipt(repoRoot, receipt) {
549
+ const dir = path.join(projectStateDir(repoRoot), "steer-receipts");
550
+ await fsp.mkdir(dir, { recursive: true });
551
+ const file = path.join(dir, `${receipt.requestId}.json`);
552
+ const temp = `${file}.${process.pid}.tmp`;
553
+ await fsp.writeFile(temp, JSON.stringify(receipt));
554
+ await fsp.rename(temp, file);
555
+ }
556
+ async function pruneSteerReceipts(repoRoot) {
557
+ const dir = path.join(projectStateDir(repoRoot), "steer-receipts");
558
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
559
+ const files = await Promise.all(entries
560
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
561
+ .map(async (entry) => ({
562
+ path: path.join(dir, entry.name),
563
+ mtimeMs: await fsp.stat(path.join(dir, entry.name)).then((stat) => stat.mtimeMs).catch(() => 0),
564
+ })));
565
+ files.sort((left, right) => left.mtimeMs - right.mtimeMs);
566
+ const now = Date.now();
567
+ for (let index = 0; index < files.length; index += 1) {
568
+ const file = files[index];
569
+ const expired = now - file.mtimeMs > 24 * 60 * 60 * 1_000;
570
+ const overCapAndObserved = files.length - index > 1_000 && now - file.mtimeMs > 5 * 60 * 1_000;
571
+ if (expired || overCapAndObserved) {
572
+ await fsp.unlink(file.path).catch(() => undefined);
573
+ await fsp.unlink(path.join(projectStateDir(repoRoot), "steer-claims", path.basename(file.path))).catch(() => undefined);
574
+ }
575
+ }
576
+ }
577
+ async function readSteerReceipt(repoRoot, requestId) {
578
+ if (!/^[A-Za-z0-9-]{8,128}$/.test(requestId))
579
+ return null;
580
+ const file = path.join(projectStateDir(repoRoot), "steer-receipts", `${requestId}.json`);
581
+ const value = await fsp.readFile(file, "utf8")
582
+ .then((raw) => JSON.parse(raw))
583
+ .catch(() => null);
584
+ if (!value)
585
+ return null;
586
+ if (value.status === "processing") {
587
+ return { requestId, status: "queued", mode: "queued" };
588
+ }
589
+ if (value.status !== "queued" &&
590
+ value.status !== "accepted" &&
591
+ value.status !== "delivered" &&
592
+ value.status !== "failed")
593
+ return null;
594
+ return {
595
+ requestId,
596
+ status: value.status,
597
+ mode: value.mode === "redirected" || value.mode === "resumed" ? value.mode : "queued",
598
+ ...(typeof value.taskId === "string" ? { taskId: value.taskId } : {}),
599
+ ...(typeof value.error === "string" ? { error: value.error } : {}),
600
+ };
601
+ }
602
+ async function resolveSteerTarget(repoRoot, id) {
603
+ const graph = await readGraph(repoRoot);
604
+ const node = graph.nodes[id] ?? Object.values(graph.nodes).find((candidate) => candidate.runId === id);
605
+ const runId = node?.runId ?? id;
606
+ if (!runId)
607
+ return null;
608
+ const run = await loadRunRecord(repoRoot, runId);
609
+ return run ? { run, ...(node ? { node } : {}) } : null;
610
+ }
611
+ function isSteerable(runStatus, nodeStatus, runMode) {
612
+ // Native TUI records may contain runtime-only mode strings outside the TS
613
+ // union. A standalone headless daemon must never relaunch those as execute
614
+ // workers. Undefined remains accepted for legacy headless records.
615
+ if (runMode && runMode !== "execute")
616
+ return false;
617
+ if (nodeStatus === "merged" || nodeStatus === "blocked" || nodeStatus === "failed")
618
+ return false;
619
+ return runStatus === "created" ||
620
+ runStatus === "running" ||
621
+ runStatus === "steering" ||
622
+ runStatus === "verifying" ||
623
+ runStatus === "completed";
624
+ }
625
+ async function steerHeadlessRun(repoRoot, id, instruction, bus, requestId) {
626
+ const target = await resolveSteerTarget(repoRoot, id);
627
+ if (!target) {
628
+ return { ok: false, status: 404, error: `unknown task: ${id}` };
629
+ }
630
+ if (!isSteerable(target.run.status, target.node?.status, target.run.mode)) {
631
+ return {
632
+ ok: false,
633
+ status: 409,
634
+ error: `task is ${target.node?.status ?? target.run.status} and cannot be steered`,
635
+ };
636
+ }
637
+ const active = target.run.status === "created" ||
638
+ target.run.status === "running" ||
639
+ target.run.status === "steering" ||
640
+ target.run.status === "verifying";
641
+ const mode = active ? "redirected" : "resumed";
642
+ // A live run without an attempt id may belong to a pre-upgrade worker whose
643
+ // unconditional writes cannot participate in the new ownership CAS. Review
644
+ // feedback is safe once that worker is finished; live redirect is not.
645
+ if (active && !target.run.process?.attemptId) {
646
+ return {
647
+ ok: false,
648
+ status: 409,
649
+ error: "this live worker predates safe web redirects; let it reach Review, then request changes",
650
+ };
651
+ }
652
+ try {
653
+ await continueRun({
654
+ runId: target.run.id,
655
+ prompt: instruction,
656
+ interrupt: active,
657
+ silent: true,
658
+ });
659
+ }
660
+ catch (error) {
661
+ return {
662
+ ok: false,
663
+ status: 409,
664
+ error: error instanceof Error ? error.message : String(error),
665
+ };
666
+ }
667
+ if (target.node) {
668
+ await updateGraph(repoRoot, (graph) => {
669
+ const node = graph.nodes[target.node.id];
670
+ if (node) {
671
+ node.status = "running";
672
+ // Revised work must pass through Review again; never retain an approval
673
+ // from the previous turn.
674
+ delete node.reviewState;
675
+ node.updatedAt = nowIso();
676
+ }
677
+ return graph;
678
+ });
679
+ }
680
+ bus.publish({
681
+ ts: nowIso(),
682
+ runId: target.run.id,
683
+ ...(target.node ? { nodeId: target.node.id } : {}),
684
+ type: "node.running",
685
+ message: mode === "redirected"
686
+ ? `Redirected ${target.node?.id ?? target.run.id} from the web board`
687
+ : `Reopened ${target.node?.id ?? target.run.id} with review feedback`,
688
+ data: { instruction },
689
+ });
690
+ return {
691
+ ok: true,
692
+ receipt: {
693
+ ok: true,
694
+ requestId,
695
+ // The attempt is installed, but the backend has not acknowledged model
696
+ // delivery yet. "accepted" is terminal UI feedback without overstating
697
+ // the handoff as model delivery.
698
+ status: "accepted",
699
+ mode,
700
+ taskId: target.node?.id ?? target.run.id,
701
+ },
702
+ };
383
703
  }
384
704
  async function handleProjectsList(res) {
385
705
  const projects = await loadProjects();
@@ -637,18 +957,29 @@ export function columnForStatus(status) {
637
957
  return "todo";
638
958
  }
639
959
  }
640
- function projectRunToNode(run, lastLine) {
960
+ function userUpdates(run) {
961
+ const turns = run.turns ?? [];
962
+ const start = turns[0]?.prompt === run.task ? 1 : 0;
963
+ return turns
964
+ .slice(start)
965
+ .filter((turn) => turn.source === "user" || turn.source === "regoal")
966
+ .map((turn) => ({ instruction: turn.prompt, ts: turn.ts, source: turn.source }));
967
+ }
968
+ function projectRunToNode(run, lastLine, graphNode) {
641
969
  return {
642
- id: run.id,
643
- title: run.taskSummary || run.task,
644
- status: run.status,
645
- column: columnForStatus(run.status),
646
- blocked: false,
970
+ // Keep the graph id stable across planned -> launched so an open issue drawer
971
+ // does not disappear exactly when the worker starts. Pure runs use run.id.
972
+ id: graphNode?.id ?? run.id,
973
+ runId: run.id,
974
+ title: graphNode?.title || run.taskSummary || run.task,
975
+ status: graphNode?.status ?? run.status,
976
+ column: graphNode ? columnForNodeStatus(graphNode.status) : columnForStatus(run.status),
977
+ blocked: graphNode?.status === "blocked",
647
978
  backend: run.backend,
648
979
  model: run.model,
649
980
  effort: run.effort,
650
981
  lastLine,
651
- tokens: null,
982
+ tokens: run.tokens ?? graphNode?.tokens ?? null,
652
983
  deps: { hard: [], soft: [] },
653
984
  createdAt: run.createdAt,
654
985
  updatedAt: run.updatedAt,
@@ -656,6 +987,7 @@ function projectRunToNode(run, lastLine) {
656
987
  ? { path: run.worktree.path, workspaceName: run.worktree.workspaceName }
657
988
  : null,
658
989
  merge: run.merge ?? null,
990
+ updates: userUpdates(run),
659
991
  };
660
992
  }
661
993
  function columnForNodeStatus(status) {
@@ -682,6 +1014,7 @@ function columnForNodeStatus(status) {
682
1014
  function projectTaskNodeToBoardNode(node, deps) {
683
1015
  return {
684
1016
  id: node.id,
1017
+ ...(node.runId ? { runId: node.runId } : {}),
685
1018
  title: node.title,
686
1019
  // RunStatus and NodeStatus share running/merged/failed; the SPA reads
687
1020
  // `column` for layout, so the broad status string is sufficient here.
@@ -698,26 +1031,22 @@ function projectTaskNodeToBoardNode(node, deps) {
698
1031
  updatedAt: node.updatedAt,
699
1032
  worktree: node.worktree ? { path: node.worktree.path, workspaceName: node.worktree.workspaceName } : null,
700
1033
  merge: null,
1034
+ updates: [],
701
1035
  };
702
1036
  }
703
1037
  // Build {from-node-id -> {hard,soft}} maps so each BoardNode carries the ids of
704
1038
  // its incoming parents, and the flat BoardEdge list for the Nest/DAG view.
705
1039
  function projectGraphEdges(graph) {
706
- // A launched node is shown on the board under its runId, but graph edges
707
- // reference node ids. Map every edge/dep endpoint to the id the board actually
708
- // displays the node under (runId once scheduled, else the node id) so Nest-view
709
- // arrows resolve to a real node instead of dangling.
710
- const displayId = (id) => graph.nodes[id]?.runId ?? id;
711
1040
  const edges = [];
712
1041
  const depsByNode = new Map();
713
1042
  for (const id of Object.keys(graph.nodes)) {
714
1043
  depsByNode.set(id, {
715
- hard: hardParents(graph, id).map(displayId),
716
- soft: softParents(graph, id).map(displayId),
1044
+ hard: hardParents(graph, id),
1045
+ soft: softParents(graph, id),
717
1046
  });
718
1047
  }
719
1048
  for (const edge of Object.values(graph.edges)) {
720
- edges.push({ from: displayId(edge.from), to: displayId(edge.to), kind: edge.type });
1049
+ edges.push({ from: edge.from, to: edge.to, kind: edge.type });
721
1050
  }
722
1051
  return { edges, depsByNode };
723
1052
  }
@@ -726,29 +1055,18 @@ async function buildSnapshot(project) {
726
1055
  const graph = await readGraph(project.repoRoot);
727
1056
  const { edges, depsByNode } = projectGraphEdges(graph);
728
1057
  const nodes = [];
729
- // Run-derived nodes are authoritative. Track which graph nodes they cover so
730
- // we do not double-list a node that has already been scheduled into a run.
731
- const coveredNodeIds = new Set();
732
- for (const node of Object.values(graph.nodes)) {
733
- if (node.runId) {
734
- coveredNodeIds.add(node.runId);
735
- }
736
- }
737
1058
  for (const run of runs) {
738
1059
  const lastLine = await lastNonEmptyLine(project.repoRoot, run.id);
739
- const boardNode = projectRunToNode(run, lastLine);
1060
+ const graphNode = Object.values(graph.nodes).find((candidate) => candidate.runId === run.id) ??
1061
+ graph.nodes[run.id];
1062
+ const boardNode = projectRunToNode(run, lastLine, graphNode);
740
1063
  // If a graph node points at this run (by runId, or by node id), the GRAPH node
741
1064
  // is authoritative for scheduled nodes: the daemon writes merged/blocked/review
742
1065
  // to graph.json, not run.json. Override the projected column/status/blocked so
743
1066
  // merged nodes leave Review and blocked nodes surface. Pure run-derived nodes
744
1067
  // (no graph entry) keep their run-projected status untouched.
745
- const graphNode = Object.values(graph.nodes).find((candidate) => candidate.runId === run.id) ??
746
- graph.nodes[run.id];
747
1068
  if (graphNode) {
748
1069
  boardNode.deps = depsByNode.get(graphNode.id) ?? boardNode.deps;
749
- boardNode.column = columnForNodeStatus(graphNode.status);
750
- boardNode.status = graphNode.status;
751
- boardNode.blocked = graphNode.status === "blocked";
752
1070
  }
753
1071
  nodes.push(boardNode);
754
1072
  }
@@ -985,7 +1303,7 @@ async function loadActivity(repoRoot) {
985
1303
  // ---------------------------------------------------------------------------
986
1304
  // HTML shell. The SPA owns all CSS; this carries no inline styles.
987
1305
  // ---------------------------------------------------------------------------
988
- function renderShell(slug) {
1306
+ function renderShell(slug, controlMode, canMutate) {
989
1307
  const slugJson = JSON.stringify(slug ?? "");
990
1308
  return `<!doctype html>
991
1309
  <html lang="en">
@@ -997,12 +1315,24 @@ function renderShell(slug) {
997
1315
  </head>
998
1316
  <body>
999
1317
  <div id="app"></div>
1000
- <script>window.__RUDDER_SLUG__ = ${slugJson}; window.__RUDDER_TOKEN__ = ${JSON.stringify(BOARD_TOKEN)}</script>
1318
+ <script>window.__RUDDER_SLUG__ = ${slugJson}; window.__RUDDER_TOKEN__ = ${JSON.stringify(BOARD_TOKEN)}; window.__RUDDER_CONTROL_MODE__ = ${JSON.stringify(controlMode)}; window.__RUDDER_CAN_MUTATE__ = ${JSON.stringify(canMutate)}</script>
1001
1319
  <script type="module" src="/board.js"></script>
1002
1320
  </body>
1003
1321
  </html>
1004
1322
  `;
1005
1323
  }
1324
+ function sameRepoPath(left, right) {
1325
+ const canonical = (value) => {
1326
+ const resolved = path.resolve(value);
1327
+ try {
1328
+ return fs.realpathSync(resolved);
1329
+ }
1330
+ catch {
1331
+ return resolved;
1332
+ }
1333
+ };
1334
+ return canonical(left) === canonical(right);
1335
+ }
1006
1336
  // ---------------------------------------------------------------------------
1007
1337
  // Helpers.
1008
1338
  // ---------------------------------------------------------------------------