@exla/rudder 0.0.0-stage → 3.4.1
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/LICENSE +100 -0
- package/README.md +733 -2
- package/assets/rudder.icns +0 -0
- package/assets/sounds/NOTICE.md +8 -0
- package/assets/sounds/ping.mp3 +0 -0
- package/dist/agent-attention.d.ts +5 -0
- package/dist/agent-attention.js +54 -0
- package/dist/agent-attention.js.map +1 -0
- package/dist/analytics.d.ts +36 -0
- package/dist/analytics.js +178 -0
- package/dist/analytics.js.map +1 -0
- package/dist/attach-session.d.ts +91 -0
- package/dist/attach-session.js +290 -0
- package/dist/attach-session.js.map +1 -0
- package/dist/auth.d.ts +36 -0
- package/dist/auth.js +397 -0
- package/dist/auth.js.map +1 -0
- package/dist/backends.d.ts +3 -0
- package/dist/backends.js +638 -0
- package/dist/backends.js.map +1 -0
- package/dist/board/board.css +1 -0
- package/dist/board/board.js +2 -0
- package/dist/board/daemon.d.ts +39 -0
- package/dist/board/daemon.js +1510 -0
- package/dist/board/daemon.js.map +1 -0
- package/dist/brain.d.ts +24 -0
- package/dist/brain.js +244 -0
- package/dist/brain.js.map +1 -0
- package/dist/bus.d.ts +9 -0
- package/dist/bus.js +23 -0
- package/dist/bus.js.map +1 -0
- package/dist/checkout-sync.d.ts +35 -0
- package/dist/checkout-sync.js +247 -0
- package/dist/checkout-sync.js.map +1 -0
- package/dist/cloud.d.ts +51 -0
- package/dist/cloud.js +4672 -0
- package/dist/cloud.js.map +1 -0
- package/dist/codex-binary.d.ts +12 -0
- package/dist/codex-binary.js +184 -0
- package/dist/codex-binary.js.map +1 -0
- package/dist/context-audit.d.ts +21 -0
- package/dist/context-audit.js +109 -0
- package/dist/context-audit.js.map +1 -0
- package/dist/daemon.d.ts +27 -0
- package/dist/daemon.js +203 -0
- package/dist/daemon.js.map +1 -0
- package/dist/effort.d.ts +9 -0
- package/dist/effort.js +72 -0
- package/dist/effort.js.map +1 -0
- package/dist/feedback.d.ts +46 -0
- package/dist/feedback.js +173 -0
- package/dist/feedback.js.map +1 -0
- package/dist/gc.d.ts +3 -0
- package/dist/gc.js +262 -0
- package/dist/gc.js.map +1 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +455 -0
- package/dist/git.js.map +1 -0
- package/dist/goal.d.ts +33 -0
- package/dist/goal.js +95 -0
- package/dist/goal.js.map +1 -0
- package/dist/graph.d.ts +93 -0
- package/dist/graph.js +372 -0
- package/dist/graph.js.map +1 -0
- package/dist/handoff.d.ts +110 -0
- package/dist/handoff.js +565 -0
- package/dist/handoff.js.map +1 -0
- package/dist/improve/advisor.d.ts +10 -0
- package/dist/improve/advisor.js +115 -0
- package/dist/improve/advisor.js.map +1 -0
- package/dist/improve/collect.d.ts +49 -0
- package/dist/improve/collect.js +274 -0
- package/dist/improve/collect.js.map +1 -0
- package/dist/improve/gate.d.ts +17 -0
- package/dist/improve/gate.js +60 -0
- package/dist/improve/gate.js.map +1 -0
- package/dist/improve/index.d.ts +9 -0
- package/dist/improve/index.js +376 -0
- package/dist/improve/index.js.map +1 -0
- package/dist/improve/judge.d.ts +32 -0
- package/dist/improve/judge.js +114 -0
- package/dist/improve/judge.js.map +1 -0
- package/dist/improve/mine.d.ts +31 -0
- package/dist/improve/mine.js +192 -0
- package/dist/improve/mine.js.map +1 -0
- package/dist/improve/propose.d.ts +51 -0
- package/dist/improve/propose.js +150 -0
- package/dist/improve/propose.js.map +1 -0
- package/dist/improve/rank.d.ts +16 -0
- package/dist/improve/rank.js +49 -0
- package/dist/improve/rank.js.map +1 -0
- package/dist/improve/schedule.d.ts +1 -0
- package/dist/improve/schedule.js +95 -0
- package/dist/improve/schedule.js.map +1 -0
- package/dist/improve/ship.d.ts +25 -0
- package/dist/improve/ship.js +82 -0
- package/dist/improve/ship.js.map +1 -0
- package/dist/improve/state.d.ts +157 -0
- package/dist/improve/state.js +264 -0
- package/dist/improve/state.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/jj-binary.d.ts +36 -0
- package/dist/jj-binary.js +143 -0
- package/dist/jj-binary.js.map +1 -0
- package/dist/jj.d.ts +163 -0
- package/dist/jj.js +926 -0
- package/dist/jj.js.map +1 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +110 -0
- package/dist/logger.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +1366 -0
- package/dist/main.js.map +1 -0
- package/dist/migration.d.ts +82 -0
- package/dist/migration.js +198 -0
- package/dist/migration.js.map +1 -0
- package/dist/models.d.ts +9 -0
- package/dist/models.js +623 -0
- package/dist/models.js.map +1 -0
- package/dist/move-home.d.ts +82 -0
- package/dist/move-home.js +472 -0
- package/dist/move-home.js.map +1 -0
- package/dist/native/darwin-arm64/rudder-native +0 -0
- package/dist/native/darwin-x64/rudder-native +0 -0
- package/dist/native/linux-x64/rudder-native +0 -0
- package/dist/native-agents.d.ts +8 -0
- package/dist/native-agents.js +178 -0
- package/dist/native-agents.js.map +1 -0
- package/dist/native-binary.d.ts +4 -0
- package/dist/native-binary.js +140 -0
- package/dist/native-binary.js.map +1 -0
- package/dist/onboarding.d.ts +11 -0
- package/dist/onboarding.js +111 -0
- package/dist/onboarding.js.map +1 -0
- package/dist/paste-bridge.d.ts +36 -0
- package/dist/paste-bridge.js +279 -0
- package/dist/paste-bridge.js.map +1 -0
- package/dist/plan-mode.d.ts +2 -0
- package/dist/plan-mode.js +22 -0
- package/dist/plan-mode.js.map +1 -0
- package/dist/planner.d.ts +27 -0
- package/dist/planner.js +587 -0
- package/dist/planner.js.map +1 -0
- package/dist/predict.d.ts +67 -0
- package/dist/predict.js +369 -0
- package/dist/predict.js.map +1 -0
- package/dist/qr.d.ts +13 -0
- package/dist/qr.js +377 -0
- package/dist/qr.js.map +1 -0
- package/dist/report.d.ts +74 -0
- package/dist/report.js +412 -0
- package/dist/report.js.map +1 -0
- package/dist/rudder-md.d.ts +33 -0
- package/dist/rudder-md.js +142 -0
- package/dist/rudder-md.js.map +1 -0
- package/dist/run-manager.d.ts +62 -0
- package/dist/run-manager.js +1400 -0
- package/dist/run-manager.js.map +1 -0
- package/dist/scheduler.d.ts +128 -0
- package/dist/scheduler.js +955 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/state.d.ts +127 -0
- package/dist/state.js +704 -0
- package/dist/state.js.map +1 -0
- package/dist/surfaces.d.ts +91 -0
- package/dist/surfaces.js +460 -0
- package/dist/surfaces.js.map +1 -0
- package/dist/task-summary.d.ts +30 -0
- package/dist/task-summary.js +472 -0
- package/dist/task-summary.js.map +1 -0
- package/dist/types.d.ts +492 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/usage.d.ts +198 -0
- package/dist/usage.js +830 -0
- package/dist/usage.js.map +1 -0
- package/dist/util.d.ts +73 -0
- package/dist/util.js +399 -0
- package/dist/util.js.map +1 -0
- package/dist/version-check.d.ts +16 -0
- package/dist/version-check.js +269 -0
- package/dist/version-check.js.map +1 -0
- package/package.json +68 -4
- package/scripts/copy-native.mjs +89 -0
- package/scripts/postinstall.mjs +34 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
// Per-daemon secret. The SPA shell embeds it (only loopback callers ever receive
|
|
9
|
+
// the shell — see the Host check below) and the browser echoes it back as the
|
|
10
|
+
// `x-rudder-token` header on every mutating request. A custom header forces a CORS
|
|
11
|
+
// preflight cross-origin (which this server never approves) and the value is
|
|
12
|
+
// unguessable, so a drive-by web page cannot forge a steer/inject/merge/cancel.
|
|
13
|
+
const BOARD_TOKEN = randomBytes(24).toString("hex");
|
|
14
|
+
// Cap request bodies so an unauthenticated POST can't buffer unbounded memory.
|
|
15
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
16
|
+
/** This process's board token (exported for tests; it is regenerated each start). */
|
|
17
|
+
export function getBoardToken() {
|
|
18
|
+
return BOARD_TOKEN;
|
|
19
|
+
}
|
|
20
|
+
/** The board binds 127.0.0.1 only, so a legitimate Host header is always loopback.
|
|
21
|
+
* Rejecting anything else defeats DNS-rebinding: an attacker page whose hostname
|
|
22
|
+
* resolves to 127.0.0.1 still sends its own (non-loopback) Host. */
|
|
23
|
+
export function isLoopbackHost(req) {
|
|
24
|
+
const host = (req.headers.host ?? "").trim().toLowerCase();
|
|
25
|
+
const name = host.replace(/:\d+$/, "");
|
|
26
|
+
return name === "127.0.0.1" || name === "localhost" || name === "[::1]" || name === "::1";
|
|
27
|
+
}
|
|
28
|
+
function isLoopbackOrigin(origin) {
|
|
29
|
+
try {
|
|
30
|
+
const h = new URL(origin).hostname.toLowerCase();
|
|
31
|
+
return h === "127.0.0.1" || h === "localhost" || h === "::1";
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function hasValidToken(req) {
|
|
38
|
+
const header = req.headers["x-rudder-token"];
|
|
39
|
+
const token = Array.isArray(header) ? header[0] : header;
|
|
40
|
+
if (typeof token !== "string" || token.length !== BOARD_TOKEN.length) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(BOARD_TOKEN));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Guard every state-mutating request. Returns true (after writing a 403) when the
|
|
51
|
+
* request must be denied: a non-loopback Origin, or a missing/invalid token. */
|
|
52
|
+
function denyMutation(req, res) {
|
|
53
|
+
const origin = req.headers.origin;
|
|
54
|
+
if (typeof origin === "string" && origin.length > 0 && !isLoopbackOrigin(origin)) {
|
|
55
|
+
sendJson(res, 403, { error: "forbidden origin" });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (!hasValidToken(req)) {
|
|
59
|
+
sendJson(res, 403, { error: "missing or invalid board token" });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
import { findProjectBySlug, loadProjects, loadRunRecord, outputPath, projectStateDir, runsDir } from "../state.js";
|
|
65
|
+
import { mergeJjRunIntoCurrentWorkspace } from "../jj.js";
|
|
66
|
+
import { hardParents, readGraph, softParents, updateGraph } from "../graph.js";
|
|
67
|
+
import { continueRun, stopRun } from "../run-manager.js";
|
|
68
|
+
import { createLogger } from "../logger.js";
|
|
69
|
+
import { mergeNodeIntoIntegration, reconcileInjection, withSchedulerLock } from "../scheduler.js";
|
|
70
|
+
import { nowIso } from "../util.js";
|
|
71
|
+
import { fetchQuota, machineResources, scanTokenUsage } from "../usage.js";
|
|
72
|
+
// dist/board/daemon.js sits next to dist/board/board.{js,css}, so the prebuilt
|
|
73
|
+
// SPA bundle resolves relative to this module's own URL.
|
|
74
|
+
const BOARD_JS_PATH = fileURLToPath(new URL("./board.js", import.meta.url));
|
|
75
|
+
const BOARD_CSS_PATH = fileURLToPath(new URL("./board.css", import.meta.url));
|
|
76
|
+
const logger = createLogger("board", { fileName: "daemon.log" });
|
|
77
|
+
// One Set of SSE clients per slug, plus one fs.watch per watched runs dir.
|
|
78
|
+
const sseClients = new Map();
|
|
79
|
+
const watchers = new Map();
|
|
80
|
+
// A second watch per slug on the tracked DECISIONS.md at the repo root: it lives
|
|
81
|
+
// outside .rudder, so a sibling decision (or `rudder remember`) must trigger a
|
|
82
|
+
// re-broadcast (memory.updated SSE) the same way a run/graph change does.
|
|
83
|
+
const decisionsWatchers = new Map();
|
|
84
|
+
const watchTimers = new Map();
|
|
85
|
+
export async function startBoardDaemon(opts) {
|
|
86
|
+
const controlMode = opts.controlMode ?? "projector";
|
|
87
|
+
// Subscribe the SSE broadcaster to the shared bus: node.*/schedule.*/merge.*
|
|
88
|
+
// events re-broadcast a fresh snapshot to every connected client (simplest
|
|
89
|
+
// correct projection; the SPA always rebuilds from the snapshot on connect).
|
|
90
|
+
let unsubscribe;
|
|
91
|
+
if (opts.bus) {
|
|
92
|
+
unsubscribe = opts.bus.subscribe(() => {
|
|
93
|
+
for (const slug of sseClients.keys()) {
|
|
94
|
+
void rebroadcastForSlug(slug);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const server = http.createServer((req, res) => {
|
|
99
|
+
handleRequest(req, res, opts.bus, controlMode, opts.repoRoot).catch((error) => {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
logger.warn("board request failed", {
|
|
102
|
+
method: req.method,
|
|
103
|
+
url: req.url,
|
|
104
|
+
repoRoot: opts.repoRoot,
|
|
105
|
+
error: message,
|
|
106
|
+
});
|
|
107
|
+
if (!res.headersSent) {
|
|
108
|
+
sendJson(res, 500, { error: message });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
try {
|
|
112
|
+
res.end();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// ignore
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
await new Promise((resolve, reject) => {
|
|
121
|
+
server.once("error", reject);
|
|
122
|
+
server.listen(opts.port, "127.0.0.1", () => {
|
|
123
|
+
server.removeListener("error", reject);
|
|
124
|
+
resolve();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
const address = server.address();
|
|
128
|
+
const port = typeof address === "object" && address ? address.port : opts.port;
|
|
129
|
+
const url = `http://127.0.0.1:${port}`;
|
|
130
|
+
const close = async () => {
|
|
131
|
+
unsubscribe?.();
|
|
132
|
+
for (const [, timer] of watchTimers) {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
}
|
|
135
|
+
watchTimers.clear();
|
|
136
|
+
for (const [, watcher] of watchers) {
|
|
137
|
+
watcher.close();
|
|
138
|
+
}
|
|
139
|
+
watchers.clear();
|
|
140
|
+
for (const [, watcher] of decisionsWatchers) {
|
|
141
|
+
watcher.close();
|
|
142
|
+
}
|
|
143
|
+
decisionsWatchers.clear();
|
|
144
|
+
for (const [, clients] of sseClients) {
|
|
145
|
+
for (const client of clients) {
|
|
146
|
+
try {
|
|
147
|
+
client.res.end();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
sseClients.clear();
|
|
155
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
156
|
+
};
|
|
157
|
+
// Determine this repo's slug for the open-browser convenience landing.
|
|
158
|
+
let slug = "";
|
|
159
|
+
try {
|
|
160
|
+
const projects = await loadProjects();
|
|
161
|
+
slug = projects.find((entry) => sameRepoPath(entry.repoRoot, opts.repoRoot))?.slug ?? "";
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
slug = "";
|
|
165
|
+
}
|
|
166
|
+
if (opts.open) {
|
|
167
|
+
openBrowser(slug ? `${url}/rudder/${slug}` : `${url}/rudder`);
|
|
168
|
+
}
|
|
169
|
+
return { port, url, close };
|
|
170
|
+
}
|
|
171
|
+
async function handleRequest(req, res, bus, controlMode, ownerRepoRoot) {
|
|
172
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
173
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
174
|
+
const method = (req.method || "GET").toUpperCase();
|
|
175
|
+
// Anti-DNS-rebinding: the board is loopback-only, so reject any request whose Host
|
|
176
|
+
// is not loopback before it can read state or serve the token-bearing shell.
|
|
177
|
+
if (!isLoopbackHost(req)) {
|
|
178
|
+
sendJson(res, 403, { error: "forbidden host" });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Static SPA bundle.
|
|
182
|
+
if (method === "GET" && pathname === "/board.js") {
|
|
183
|
+
await sendStatic(res, BOARD_JS_PATH, "text/javascript; charset=utf-8");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (method === "GET" && pathname === "/board.css") {
|
|
187
|
+
await sendStatic(res, BOARD_CSS_PATH, "text/css; charset=utf-8");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Usage dashboard (`U` in the TUI): quota meters, token costs, machine
|
|
191
|
+
// resources. Read-only, machine-wide (not per project), loopback-only like
|
|
192
|
+
// everything else here.
|
|
193
|
+
if (method === "GET" && (pathname === "/usage" || pathname === "/rudder/usage")) {
|
|
194
|
+
sendHtml(res, renderShell("", controlMode, false, "usage"));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (method === "GET" && pathname === "/api/usage/tokens") {
|
|
198
|
+
const raw = url.searchParams.get("range") ?? "30d";
|
|
199
|
+
const range = raw === "7d" || raw === "90d" ? raw : "30d";
|
|
200
|
+
try {
|
|
201
|
+
const projects = (await loadProjects()).map((p) => ({ slug: p.slug, name: p.name, repoRoot: p.repoRoot }));
|
|
202
|
+
sendJson(res, 200, await scanTokenUsage({ range, projects }));
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
sendJson(res, 500, { error: String(error?.message ?? error) });
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (method === "GET" && pathname === "/api/usage/quota") {
|
|
210
|
+
try {
|
|
211
|
+
sendJson(res, 200, await fetchQuota({ force: url.searchParams.get("refresh") === "1" }));
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
sendJson(res, 500, { error: String(error?.message ?? error) });
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (method === "GET" && pathname === "/api/usage/machine") {
|
|
219
|
+
try {
|
|
220
|
+
sendJson(res, 200, await machineResources());
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
sendJson(res, 500, { error: String(error?.message ?? error) });
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// API routes: /api/projects[/:slug/...]
|
|
228
|
+
if (pathname === "/api/projects" && method === "GET") {
|
|
229
|
+
await handleProjectsList(res);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const apiMatch = pathname.match(/^\/api\/projects\/([^/]+)(\/.*)?$/);
|
|
233
|
+
if (apiMatch) {
|
|
234
|
+
const slug = apiMatch[1] ?? "";
|
|
235
|
+
const rest = apiMatch[2] ?? "";
|
|
236
|
+
await handleProjectApi(req, res, method, slug, rest, url, bus, controlMode, ownerRepoRoot);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// SPA shell for the index and per-project routes.
|
|
240
|
+
if (method === "GET" && (pathname === "/" || pathname === "/rudder")) {
|
|
241
|
+
sendHtml(res, renderShell("", controlMode, false));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const slugMatch = pathname.match(/^\/rudder\/([^/]+)\/?$/);
|
|
245
|
+
if (method === "GET" && slugMatch) {
|
|
246
|
+
const slug = slugMatch[1] ?? "";
|
|
247
|
+
const project = await findProjectBySlug(slug);
|
|
248
|
+
const canMutate = Boolean(project && sameRepoPath(project.repoRoot, ownerRepoRoot));
|
|
249
|
+
sendHtml(res, renderShell(slug, controlMode, canMutate));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
sendJson(res, 404, { error: "not found" });
|
|
253
|
+
}
|
|
254
|
+
async function handleProjectApi(req, res, method, slug, rest, url, bus, controlMode = "projector", ownerRepoRoot = "") {
|
|
255
|
+
const project = await findProjectBySlug(slug);
|
|
256
|
+
if (!project) {
|
|
257
|
+
sendJson(res, 404, { error: `unknown project: ${slug}` });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// Every mutating route is a POST. Require the secret token (+ loopback origin)
|
|
261
|
+
// before any of them run, so a cross-site page cannot steer/inject/merge/cancel.
|
|
262
|
+
if (method === "POST" && denyMutation(req, res)) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// One daemon owns exactly one repository's scheduler or native-projector
|
|
266
|
+
// channel. Other registered projects remain readable in the overview, but
|
|
267
|
+
// mutations here would route work to the wrong owner.
|
|
268
|
+
if (method === "POST" &&
|
|
269
|
+
ownerRepoRoot &&
|
|
270
|
+
!sameRepoPath(project.repoRoot, ownerRepoRoot)) {
|
|
271
|
+
sendJson(res, 409, {
|
|
272
|
+
error: "this board daemon is read-only for that project; open its own Rudder board to make changes",
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (rest === "/state" && method === "GET") {
|
|
277
|
+
const snapshot = await buildSnapshot(project);
|
|
278
|
+
sendJson(res, 200, snapshot);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (rest === "/events" && method === "GET") {
|
|
282
|
+
await handleSse(req, res, project);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const steerReceiptMatch = rest.match(/^\/steer-receipts\/([A-Za-z0-9-]+)$/);
|
|
286
|
+
if (steerReceiptMatch && method === "GET") {
|
|
287
|
+
const requestId = steerReceiptMatch[1] ?? "";
|
|
288
|
+
const receipt = await readSteerReceipt(project.repoRoot, requestId);
|
|
289
|
+
if (receipt) {
|
|
290
|
+
sendJson(res, receipt.status === "queued" ? 202 : 200, receipt);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
sendJson(res, 202, { requestId, status: "queued", mode: "queued" });
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (rest === "/tasks" && method === "POST") {
|
|
298
|
+
const body = await readJsonBody(req);
|
|
299
|
+
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
|
300
|
+
if (!prompt) {
|
|
301
|
+
sendJson(res, 400, { error: "missing prompt" });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// In projector mode graph.json is a one-way mirror written by the native
|
|
305
|
+
// dashboard. Queue the request through its durable control inbox so the TUI
|
|
306
|
+
// handles it exactly like text entered in the task pane. Writing graph.json
|
|
307
|
+
// here would create a card the TUI never schedules.
|
|
308
|
+
if (controlMode === "projector") {
|
|
309
|
+
const queued = await writeSteerRequest(project.repoRoot, "conductor", prompt, "task", requestIdFromBody(body));
|
|
310
|
+
sendJson(res, queued.status === "queued" ? 202 : 200, { ...queued, nodeIds: [] });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (!bus) {
|
|
314
|
+
sendJson(res, 503, { error: "scheduler not available" });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const requestId = requestIdFromBody(body);
|
|
318
|
+
const claimed = await claimSteerRequest(project.repoRoot, requestId, "conductor", prompt);
|
|
319
|
+
if (!claimed) {
|
|
320
|
+
const existing = await readSteerReceipt(project.repoRoot, requestId);
|
|
321
|
+
if (existing?.taskId) {
|
|
322
|
+
sendJson(res, 200, {
|
|
323
|
+
requestId,
|
|
324
|
+
status: existing.status,
|
|
325
|
+
nodeId: existing.taskId,
|
|
326
|
+
nodeIds: [existing.taskId],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
sendJson(res, 202, { requestId, status: "queued", nodeIds: [] });
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
// The injection chokepoint: a typed task becomes a NEW node reconciled
|
|
335
|
+
// against the frontier (never blindly appended), then the scheduler takes
|
|
336
|
+
// over. Routes through reconcileInjection rather than plain startRun.
|
|
337
|
+
const title = typeof body.title === "string" ? body.title.trim() : undefined;
|
|
338
|
+
const result = await inRepo(project.repoRoot, () => reconcileInjection(project.repoRoot, { prompt, ...(title ? { title } : {}) }, bus));
|
|
339
|
+
await writeSteerReceipt(project.repoRoot, {
|
|
340
|
+
requestId,
|
|
341
|
+
status: "accepted",
|
|
342
|
+
mode: "queued",
|
|
343
|
+
taskId: result.nodeId,
|
|
344
|
+
});
|
|
345
|
+
sendJson(res, 200, { requestId, nodeId: result.nodeId, nodeIds: [result.nodeId] });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const logMatch = rest.match(/^\/tasks\/([^/]+)\/log$/);
|
|
349
|
+
if (logMatch && method === "GET") {
|
|
350
|
+
const id = logMatch[1] ?? "";
|
|
351
|
+
const tail = Number.parseInt(url.searchParams.get("tail") ?? "200", 10);
|
|
352
|
+
const target = await resolveSteerTarget(project.repoRoot, id);
|
|
353
|
+
const text = await readLogTail(project.repoRoot, target?.run.id ?? id, Number.isFinite(tail) ? tail : 200);
|
|
354
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
355
|
+
res.end(text);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// Approve a node in review: mark reviewState "approved" and merge it into the
|
|
359
|
+
// integration trunk via the scheduler (the daemon-owned jj merge path).
|
|
360
|
+
const approveMatch = rest.match(/^\/tasks\/([^/]+)\/approve$/);
|
|
361
|
+
if (approveMatch && method === "POST") {
|
|
362
|
+
const id = approveMatch[1] ?? "";
|
|
363
|
+
if (controlMode === "projector") {
|
|
364
|
+
const queued = await writeSteerRequest(project.repoRoot, id, "merge", "merge");
|
|
365
|
+
sendJson(res, queued.status === "queued" ? 202 : 200, queued);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (!bus) {
|
|
369
|
+
sendJson(res, 503, { error: "scheduler not available" });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const result = await inRepo(project.repoRoot, async () => {
|
|
373
|
+
let target;
|
|
374
|
+
await updateGraph(project.repoRoot, (graph) => {
|
|
375
|
+
const node = graph.nodes[id] ?? Object.values(graph.nodes).find((candidate) => candidate.runId === id);
|
|
376
|
+
if (node) {
|
|
377
|
+
node.reviewState = "approved";
|
|
378
|
+
node.updatedAt = nowIso();
|
|
379
|
+
target = node;
|
|
380
|
+
}
|
|
381
|
+
return graph;
|
|
382
|
+
});
|
|
383
|
+
if (!target) {
|
|
384
|
+
return { ok: false };
|
|
385
|
+
}
|
|
386
|
+
// Serialize against the auto-scheduler's ticks/transitions so a manual
|
|
387
|
+
// approve never merges against a stale integration head.
|
|
388
|
+
await withSchedulerLock(project.repoRoot, () => mergeNodeIntoIntegration(project.repoRoot, target, bus));
|
|
389
|
+
return { ok: true, nodeId: target.id };
|
|
390
|
+
});
|
|
391
|
+
if (!result.ok) {
|
|
392
|
+
sendJson(res, 404, { error: `unknown node: ${id}` });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
sendJson(res, 200, { ok: true, nodeId: result.nodeId });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const mergeMatch = rest.match(/^\/tasks\/([^/]+)\/merge$/);
|
|
399
|
+
if (mergeMatch && method === "POST") {
|
|
400
|
+
const id = mergeMatch[1] ?? "";
|
|
401
|
+
if (controlMode === "projector") {
|
|
402
|
+
const queued = await writeSteerRequest(project.repoRoot, id, "merge", "merge");
|
|
403
|
+
sendJson(res, queued.status === "queued" ? 202 : 200, {
|
|
404
|
+
...queued,
|
|
405
|
+
conflictedFiles: [],
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// A graph node id routes through the daemon's integration merge (same path
|
|
410
|
+
// as approve). A bare run id keeps the legacy run-merge for unmanaged runs.
|
|
411
|
+
if (bus) {
|
|
412
|
+
const graph = await readGraph(project.repoRoot);
|
|
413
|
+
const node = graph.nodes[id] ?? Object.values(graph.nodes).find((candidate) => candidate.runId === id);
|
|
414
|
+
if (node) {
|
|
415
|
+
await inRepo(project.repoRoot, () => withSchedulerLock(project.repoRoot, () => mergeNodeIntoIntegration(project.repoRoot, node, bus)));
|
|
416
|
+
const fresh = await readGraph(project.repoRoot);
|
|
417
|
+
const refreshed = fresh.nodes[node.id];
|
|
418
|
+
// A graph node that did not merge (blocked) signals a conflict to the UI.
|
|
419
|
+
const conflicted = refreshed?.status === "blocked" || refreshed?.merge?.status === "conflict";
|
|
420
|
+
sendJson(res, 200, {
|
|
421
|
+
status: conflicted ? "merge-conflict" : refreshed?.status ?? "merged",
|
|
422
|
+
conflictedFiles: refreshed?.merge?.conflictedFiles ?? [],
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const run = await loadRunRecord(project.repoRoot, id);
|
|
428
|
+
if (!run) {
|
|
429
|
+
sendJson(res, 404, { error: `unknown run: ${id}` });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const merged = await inRepo(project.repoRoot, () => mergeJjRunIntoCurrentWorkspace(run));
|
|
433
|
+
// mergeJjRunIntoCurrentWorkspace records merge.status "conflict"; normalize to
|
|
434
|
+
// the single "merge-conflict" signal the UI reacts to so server + UI agree.
|
|
435
|
+
const conflicted = merged.merge?.status === "conflict" || merged.status === "merge-conflict";
|
|
436
|
+
sendJson(res, 200, {
|
|
437
|
+
status: conflicted ? "merge-conflict" : merged.merge?.status ?? merged.status,
|
|
438
|
+
conflictedFiles: merged.merge?.conflictedFiles ?? [],
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const cancelMatch = rest.match(/^\/tasks\/([^/]+)\/cancel$/);
|
|
443
|
+
if (cancelMatch && method === "POST") {
|
|
444
|
+
const id = cancelMatch[1] ?? "";
|
|
445
|
+
if (controlMode === "projector") {
|
|
446
|
+
const queued = await writeSteerRequest(project.repoRoot, id, "cancel", "cancel");
|
|
447
|
+
sendJson(res, queued.status === "queued" ? 202 : 200, queued);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const graph = await readGraph(project.repoRoot);
|
|
451
|
+
const node = graph.nodes[id] ?? Object.values(graph.nodes).find((candidate) => candidate.runId === id);
|
|
452
|
+
await inRepo(project.repoRoot, () => stopRun(node?.runId ?? id, { silent: true }));
|
|
453
|
+
sendJson(res, 200, { ok: true });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
// Steer a running agent (or the conductor) from the browser. We drop a JSON
|
|
457
|
+
// instruction into the .rudder/steer/ inbox; the native TUI polls it each tick
|
|
458
|
+
// and injects the text straight into the matching agent's PTY (see
|
|
459
|
+
// poll_steer_inbox in native/src/main.rs). File-based so it works whether or
|
|
460
|
+
// not the daemon owns the scheduler (projector-only TUI sessions included).
|
|
461
|
+
const steerMatch = rest.match(/^\/tasks\/([^/]+)\/steer$/);
|
|
462
|
+
if (steerMatch && method === "POST") {
|
|
463
|
+
const id = steerMatch[1] ?? "";
|
|
464
|
+
const body = await readJsonBody(req);
|
|
465
|
+
const instruction = typeof body.instruction === "string"
|
|
466
|
+
? body.instruction.trim()
|
|
467
|
+
: typeof body.text === "string"
|
|
468
|
+
? body.text.trim()
|
|
469
|
+
: "";
|
|
470
|
+
if (!instruction) {
|
|
471
|
+
sendJson(res, 400, { error: "missing instruction" });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (instruction.length > 8_000) {
|
|
475
|
+
sendJson(res, 413, { error: "instruction is too long (maximum 8000 characters)" });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (controlMode === "scheduler") {
|
|
479
|
+
if (!bus) {
|
|
480
|
+
sendJson(res, 503, { error: "scheduler not available" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const requestId = requestIdFromBody(body);
|
|
484
|
+
const claimed = await claimSteerRequest(project.repoRoot, requestId, id, instruction);
|
|
485
|
+
if (!claimed) {
|
|
486
|
+
const existing = await readSteerReceipt(project.repoRoot, requestId);
|
|
487
|
+
sendJson(res, existing?.status === "queued" ? 202 : 200, existing ?? {
|
|
488
|
+
requestId,
|
|
489
|
+
status: "queued",
|
|
490
|
+
mode: "queued",
|
|
491
|
+
taskId: id,
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const result = await inRepo(project.repoRoot, () => withSchedulerLock(project.repoRoot, () => steerHeadlessRun(project.repoRoot, id, instruction, bus, requestId)));
|
|
496
|
+
if (!result.ok) {
|
|
497
|
+
await writeSteerReceipt(project.repoRoot, {
|
|
498
|
+
requestId,
|
|
499
|
+
status: "failed",
|
|
500
|
+
mode: "queued",
|
|
501
|
+
taskId: id,
|
|
502
|
+
error: result.error,
|
|
503
|
+
});
|
|
504
|
+
sendJson(res, result.status, { error: result.error });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
await writeSteerReceipt(project.repoRoot, result.receipt);
|
|
508
|
+
sendJson(res, 200, result.receipt);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const target = await resolveSteerTarget(project.repoRoot, id);
|
|
512
|
+
if (!target) {
|
|
513
|
+
sendJson(res, 404, { error: `unknown task: ${id}` });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!isSteerable(target.run.status, target.node?.status)) {
|
|
517
|
+
sendJson(res, 409, { error: `task is ${target.node?.status ?? target.run.status} and cannot be steered` });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const queued = await writeSteerRequest(project.repoRoot, target.node?.id ?? id, instruction, "steer", requestIdFromBody(body));
|
|
521
|
+
sendJson(res, queued.status === "queued" ? 202 : 200, queued);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
// Fleet nudge: one message to EVERY in-flight worker at once. Same inbox and
|
|
525
|
+
// at-most-once ledger as a single steer, but with no task id — the native
|
|
526
|
+
// poller resolves the target set itself, since only it knows which rows still
|
|
527
|
+
// hold a live terminal and which have to be resumed.
|
|
528
|
+
if (rest === "/nudge-all" && method === "POST") {
|
|
529
|
+
const body = await readJsonBody(req);
|
|
530
|
+
const instruction = typeof body.instruction === "string"
|
|
531
|
+
? body.instruction.trim()
|
|
532
|
+
: typeof body.text === "string"
|
|
533
|
+
? body.text.trim()
|
|
534
|
+
: "";
|
|
535
|
+
if (!instruction) {
|
|
536
|
+
sendJson(res, 400, { error: "missing instruction" });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (instruction.length > 8_000) {
|
|
540
|
+
sendJson(res, 413, { error: "instruction is too long (maximum 8000 characters)" });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const queued = await writeSteerRequest(project.repoRoot, "fleet", instruction, "nudge-all", requestIdFromBody(body));
|
|
544
|
+
sendJson(res, queued.status === "queued" ? 202 : 200, queued);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
// Conductor-level steer: same inbox, target "conductor".
|
|
548
|
+
if (rest === "/steer" && method === "POST") {
|
|
549
|
+
const body = await readJsonBody(req);
|
|
550
|
+
const instruction = typeof body.instruction === "string"
|
|
551
|
+
? body.instruction.trim()
|
|
552
|
+
: typeof body.text === "string"
|
|
553
|
+
? body.text.trim()
|
|
554
|
+
: "";
|
|
555
|
+
if (!instruction) {
|
|
556
|
+
sendJson(res, 400, { error: "missing instruction" });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (instruction.length > 8_000) {
|
|
560
|
+
sendJson(res, 413, { error: "instruction is too long (maximum 8000 characters)" });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (controlMode === "scheduler") {
|
|
564
|
+
sendJson(res, 409, {
|
|
565
|
+
error: "the standalone board has no live conductor; create a task or steer a running worker instead",
|
|
566
|
+
});
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const queued = await writeSteerRequest(project.repoRoot, "conductor", instruction, "steer", requestIdFromBody(body));
|
|
570
|
+
sendJson(res, queued.status === "queued" ? 202 : 200, queued);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
sendJson(res, 404, { error: "not found" });
|
|
574
|
+
}
|
|
575
|
+
// Write one steer request into .rudder/steer/. Filename is timestamp-prefixed so
|
|
576
|
+
// the native poller applies queued steers in order; the id is sanitized so a node
|
|
577
|
+
// id never escapes the inbox dir.
|
|
578
|
+
async function writeSteerRequest(repoRoot, taskId, instruction, kind, requestedRequestId) {
|
|
579
|
+
const dir = path.join(projectStateDir(repoRoot), "steer");
|
|
580
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
581
|
+
await pruneSteerReceipts(repoRoot).catch(() => undefined);
|
|
582
|
+
const ts = Date.now();
|
|
583
|
+
const requestId = requestedRequestId ?? randomUUID();
|
|
584
|
+
const claimed = await claimSteerRequest(repoRoot, requestId, taskId, instruction);
|
|
585
|
+
if (!claimed) {
|
|
586
|
+
const existing = await readSteerReceipt(repoRoot, requestId);
|
|
587
|
+
return { ok: true, ...(existing ?? { requestId, status: "queued", mode: "queued", taskId }) };
|
|
588
|
+
}
|
|
589
|
+
const safeId = (taskId || "conductor").replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64) || "conductor";
|
|
590
|
+
// hrtime + UUID prevents same-millisecond steers from overwriting each other;
|
|
591
|
+
// the zero-padded wall-clock prefix retains delivery order for the TUI poller.
|
|
592
|
+
const sequence = process.hrtime.bigint().toString().padStart(20, "0");
|
|
593
|
+
const file = path.join(dir, `${String(ts).padStart(13, "0")}-${sequence}-${safeId}-${requestId}.json`);
|
|
594
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
595
|
+
await fsp.writeFile(tmp, JSON.stringify({ requestId, kind, taskId, instruction, ts: new Date(ts).toISOString() }));
|
|
596
|
+
await fsp.rename(tmp, file);
|
|
597
|
+
return { ok: true, requestId, status: "queued", mode: "queued", taskId };
|
|
598
|
+
}
|
|
599
|
+
function requestIdFromBody(body) {
|
|
600
|
+
const value = typeof body.requestId === "string" ? body.requestId.trim() : "";
|
|
601
|
+
return /^[A-Za-z0-9-]{8,128}$/.test(value) ? value : randomUUID();
|
|
602
|
+
}
|
|
603
|
+
async function claimSteerRequest(repoRoot, requestId, taskId, instruction) {
|
|
604
|
+
const dir = path.join(projectStateDir(repoRoot), "steer-claims");
|
|
605
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
606
|
+
try {
|
|
607
|
+
await fsp.writeFile(path.join(dir, `${requestId}.json`), JSON.stringify({ requestId, taskId, instruction, claimedAt: nowIso() }), { flag: "wx" });
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function writeSteerReceipt(repoRoot, receipt) {
|
|
618
|
+
const dir = path.join(projectStateDir(repoRoot), "steer-receipts");
|
|
619
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
620
|
+
const file = path.join(dir, `${receipt.requestId}.json`);
|
|
621
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
622
|
+
await fsp.writeFile(temp, JSON.stringify(receipt));
|
|
623
|
+
await fsp.rename(temp, file);
|
|
624
|
+
}
|
|
625
|
+
async function pruneSteerReceipts(repoRoot) {
|
|
626
|
+
const dir = path.join(projectStateDir(repoRoot), "steer-receipts");
|
|
627
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
628
|
+
const files = await Promise.all(entries
|
|
629
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
630
|
+
.map(async (entry) => ({
|
|
631
|
+
path: path.join(dir, entry.name),
|
|
632
|
+
mtimeMs: await fsp.stat(path.join(dir, entry.name)).then((stat) => stat.mtimeMs).catch(() => 0),
|
|
633
|
+
})));
|
|
634
|
+
files.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
635
|
+
const now = Date.now();
|
|
636
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
637
|
+
const file = files[index];
|
|
638
|
+
const expired = now - file.mtimeMs > 24 * 60 * 60 * 1_000;
|
|
639
|
+
const overCapAndObserved = files.length - index > 1_000 && now - file.mtimeMs > 5 * 60 * 1_000;
|
|
640
|
+
if (expired || overCapAndObserved) {
|
|
641
|
+
await fsp.unlink(file.path).catch(() => undefined);
|
|
642
|
+
await fsp.unlink(path.join(projectStateDir(repoRoot), "steer-claims", path.basename(file.path))).catch(() => undefined);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async function readSteerReceipt(repoRoot, requestId) {
|
|
647
|
+
if (!/^[A-Za-z0-9-]{8,128}$/.test(requestId))
|
|
648
|
+
return null;
|
|
649
|
+
const file = path.join(projectStateDir(repoRoot), "steer-receipts", `${requestId}.json`);
|
|
650
|
+
const value = await fsp.readFile(file, "utf8")
|
|
651
|
+
.then((raw) => JSON.parse(raw))
|
|
652
|
+
.catch(() => null);
|
|
653
|
+
if (!value)
|
|
654
|
+
return null;
|
|
655
|
+
if (value.status === "processing") {
|
|
656
|
+
return { requestId, status: "queued", mode: "queued" };
|
|
657
|
+
}
|
|
658
|
+
if (value.status !== "queued" &&
|
|
659
|
+
value.status !== "accepted" &&
|
|
660
|
+
value.status !== "delivered" &&
|
|
661
|
+
value.status !== "failed")
|
|
662
|
+
return null;
|
|
663
|
+
return {
|
|
664
|
+
requestId,
|
|
665
|
+
status: value.status,
|
|
666
|
+
mode: value.mode === "redirected" || value.mode === "resumed" ? value.mode : "queued",
|
|
667
|
+
...(typeof value.taskId === "string" ? { taskId: value.taskId } : {}),
|
|
668
|
+
...(typeof value.error === "string" ? { error: value.error } : {}),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async function resolveSteerTarget(repoRoot, id) {
|
|
672
|
+
const graph = await readGraph(repoRoot);
|
|
673
|
+
const node = graph.nodes[id] ?? Object.values(graph.nodes).find((candidate) => candidate.runId === id);
|
|
674
|
+
const runId = node?.runId ?? id;
|
|
675
|
+
if (!runId)
|
|
676
|
+
return null;
|
|
677
|
+
const run = await loadRunRecord(repoRoot, runId);
|
|
678
|
+
return run ? { run, ...(node ? { node } : {}) } : null;
|
|
679
|
+
}
|
|
680
|
+
function isSteerable(runStatus, nodeStatus, runMode) {
|
|
681
|
+
// Native TUI records may contain runtime-only mode strings outside the TS
|
|
682
|
+
// union. A standalone headless daemon must never relaunch those as execute
|
|
683
|
+
// workers. Undefined remains accepted for legacy headless records.
|
|
684
|
+
if (runMode && runMode !== "execute")
|
|
685
|
+
return false;
|
|
686
|
+
if (nodeStatus === "merged" || nodeStatus === "blocked" || nodeStatus === "failed")
|
|
687
|
+
return false;
|
|
688
|
+
return runStatus === "created" ||
|
|
689
|
+
runStatus === "running" ||
|
|
690
|
+
runStatus === "steering" ||
|
|
691
|
+
runStatus === "verifying" ||
|
|
692
|
+
runStatus === "completed";
|
|
693
|
+
}
|
|
694
|
+
async function steerHeadlessRun(repoRoot, id, instruction, bus, requestId) {
|
|
695
|
+
const target = await resolveSteerTarget(repoRoot, id);
|
|
696
|
+
if (!target) {
|
|
697
|
+
return { ok: false, status: 404, error: `unknown task: ${id}` };
|
|
698
|
+
}
|
|
699
|
+
if (!isSteerable(target.run.status, target.node?.status, target.run.mode)) {
|
|
700
|
+
return {
|
|
701
|
+
ok: false,
|
|
702
|
+
status: 409,
|
|
703
|
+
error: `task is ${target.node?.status ?? target.run.status} and cannot be steered`,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const active = target.run.status === "created" ||
|
|
707
|
+
target.run.status === "running" ||
|
|
708
|
+
target.run.status === "steering" ||
|
|
709
|
+
target.run.status === "verifying";
|
|
710
|
+
const mode = active ? "redirected" : "resumed";
|
|
711
|
+
// A live run without an attempt id may belong to a pre-upgrade worker whose
|
|
712
|
+
// unconditional writes cannot participate in the new ownership CAS. Review
|
|
713
|
+
// feedback is safe once that worker is finished; live redirect is not.
|
|
714
|
+
if (active && !target.run.process?.attemptId) {
|
|
715
|
+
return {
|
|
716
|
+
ok: false,
|
|
717
|
+
status: 409,
|
|
718
|
+
error: "this live worker predates safe web redirects; let it reach Review, then request changes",
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
await continueRun({
|
|
723
|
+
runId: target.run.id,
|
|
724
|
+
prompt: instruction,
|
|
725
|
+
interrupt: active,
|
|
726
|
+
silent: true,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
status: 409,
|
|
733
|
+
error: error instanceof Error ? error.message : String(error),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (target.node) {
|
|
737
|
+
await updateGraph(repoRoot, (graph) => {
|
|
738
|
+
const node = graph.nodes[target.node.id];
|
|
739
|
+
if (node) {
|
|
740
|
+
node.status = "running";
|
|
741
|
+
// Revised work must pass through Review again; never retain an approval
|
|
742
|
+
// from the previous turn.
|
|
743
|
+
delete node.reviewState;
|
|
744
|
+
node.updatedAt = nowIso();
|
|
745
|
+
}
|
|
746
|
+
return graph;
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
bus.publish({
|
|
750
|
+
ts: nowIso(),
|
|
751
|
+
runId: target.run.id,
|
|
752
|
+
...(target.node ? { nodeId: target.node.id } : {}),
|
|
753
|
+
type: "node.running",
|
|
754
|
+
message: mode === "redirected"
|
|
755
|
+
? `Redirected ${target.node?.id ?? target.run.id} from the web board`
|
|
756
|
+
: `Reopened ${target.node?.id ?? target.run.id} with review feedback`,
|
|
757
|
+
data: { instruction },
|
|
758
|
+
});
|
|
759
|
+
return {
|
|
760
|
+
ok: true,
|
|
761
|
+
receipt: {
|
|
762
|
+
ok: true,
|
|
763
|
+
requestId,
|
|
764
|
+
// The attempt is installed, but the backend has not acknowledged model
|
|
765
|
+
// delivery yet. "accepted" is terminal UI feedback without overstating
|
|
766
|
+
// the handoff as model delivery.
|
|
767
|
+
status: "accepted",
|
|
768
|
+
mode,
|
|
769
|
+
taskId: target.node?.id ?? target.run.id,
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
async function handleProjectsList(res) {
|
|
774
|
+
const projects = await loadProjects();
|
|
775
|
+
const summaries = await Promise.all(projects.map((entry) => buildProjectSummary(entry)));
|
|
776
|
+
sendJson(res, 200, { projects: summaries });
|
|
777
|
+
}
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
// SSE: emit a full snapshot on connect, then deltas driven by fs.watch.
|
|
780
|
+
// ---------------------------------------------------------------------------
|
|
781
|
+
async function handleSse(req, res, project) {
|
|
782
|
+
res.writeHead(200, {
|
|
783
|
+
"content-type": "text/event-stream",
|
|
784
|
+
"cache-control": "no-cache, no-transform",
|
|
785
|
+
connection: "keep-alive",
|
|
786
|
+
});
|
|
787
|
+
res.write(": connected\n\n");
|
|
788
|
+
const client = { res };
|
|
789
|
+
let set = sseClients.get(project.slug);
|
|
790
|
+
if (!set) {
|
|
791
|
+
set = new Set();
|
|
792
|
+
sseClients.set(project.slug, set);
|
|
793
|
+
}
|
|
794
|
+
set.add(client);
|
|
795
|
+
ensureWatcher(project);
|
|
796
|
+
// Full snapshot on connect.
|
|
797
|
+
const snapshot = await buildSnapshot(project);
|
|
798
|
+
writeSseFrame(res, "snapshot", snapshot);
|
|
799
|
+
const ping = setInterval(() => {
|
|
800
|
+
try {
|
|
801
|
+
res.write(": ping\n\n");
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
// ignore
|
|
805
|
+
}
|
|
806
|
+
}, 15000);
|
|
807
|
+
ping.unref?.();
|
|
808
|
+
const cleanup = () => {
|
|
809
|
+
clearInterval(ping);
|
|
810
|
+
const clients = sseClients.get(project.slug);
|
|
811
|
+
if (clients) {
|
|
812
|
+
clients.delete(client);
|
|
813
|
+
if (clients.size === 0) {
|
|
814
|
+
sseClients.delete(project.slug);
|
|
815
|
+
const watcher = watchers.get(project.slug);
|
|
816
|
+
if (watcher) {
|
|
817
|
+
watcher.close();
|
|
818
|
+
watchers.delete(project.slug);
|
|
819
|
+
}
|
|
820
|
+
const decisionsWatcher = decisionsWatchers.get(project.slug);
|
|
821
|
+
if (decisionsWatcher) {
|
|
822
|
+
decisionsWatcher.close();
|
|
823
|
+
decisionsWatchers.delete(project.slug);
|
|
824
|
+
}
|
|
825
|
+
const timer = watchTimers.get(project.slug);
|
|
826
|
+
if (timer) {
|
|
827
|
+
clearTimeout(timer);
|
|
828
|
+
watchTimers.delete(project.slug);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
req.on("close", cleanup);
|
|
834
|
+
res.on("close", cleanup);
|
|
835
|
+
}
|
|
836
|
+
function ensureWatcher(project) {
|
|
837
|
+
if (watchers.has(project.slug)) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
// Watch the whole .rudder dir recursively: this covers both runs/<id>/*.json
|
|
841
|
+
// (worker-owned execution state) and graph.json (daemon-owned DAG topology),
|
|
842
|
+
// so plan/edge changes broadcast a fresh snapshot just like run changes.
|
|
843
|
+
const dir = projectStateDir(project.repoRoot);
|
|
844
|
+
const runsPath = runsDir(project.repoRoot);
|
|
845
|
+
try {
|
|
846
|
+
fs.mkdirSync(runsPath, { recursive: true });
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
// ignore
|
|
850
|
+
}
|
|
851
|
+
let watcher;
|
|
852
|
+
try {
|
|
853
|
+
watcher = fs.watch(dir, { recursive: true }, () => scheduleBroadcast(project));
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
try {
|
|
857
|
+
// Non-recursive fallback: watch the runs dir (most active) since some
|
|
858
|
+
// platforms reject recursive watches.
|
|
859
|
+
watcher = fs.watch(runsPath, () => scheduleBroadcast(project));
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
watcher.on("error", () => undefined);
|
|
866
|
+
watchers.set(project.slug, watcher);
|
|
867
|
+
ensureDecisionsWatcher(project);
|
|
868
|
+
}
|
|
869
|
+
// Watch the repo-root DECISIONS.md (created on first render if absent) so an
|
|
870
|
+
// agent or `rudder remember` appending a decision fires a re-broadcast and the
|
|
871
|
+
// memory.updated SSE, exactly like a run/graph change inside .rudder.
|
|
872
|
+
function ensureDecisionsWatcher(project) {
|
|
873
|
+
if (decisionsWatchers.has(project.slug)) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const decisionsPath = path.join(project.repoRoot, "DECISIONS.md");
|
|
877
|
+
try {
|
|
878
|
+
// Watching a possibly-absent file throws; watch the repo root and filter for
|
|
879
|
+
// DECISIONS.md so the surface is covered even before it is first created.
|
|
880
|
+
const watcher = fs.watch(project.repoRoot, (_event, filename) => {
|
|
881
|
+
if (!filename || filename === "DECISIONS.md") {
|
|
882
|
+
scheduleBroadcast(project);
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
watcher.on("error", () => undefined);
|
|
886
|
+
decisionsWatchers.set(project.slug, watcher);
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
// Fall back to a direct file watch if the dir watch is rejected.
|
|
890
|
+
try {
|
|
891
|
+
const watcher = fs.watch(decisionsPath, () => scheduleBroadcast(project));
|
|
892
|
+
watcher.on("error", () => undefined);
|
|
893
|
+
decisionsWatchers.set(project.slug, watcher);
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
// ignore: the .rudder watch + bus still cover most updates.
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
function scheduleBroadcast(project) {
|
|
901
|
+
const existing = watchTimers.get(project.slug);
|
|
902
|
+
if (existing) {
|
|
903
|
+
clearTimeout(existing);
|
|
904
|
+
}
|
|
905
|
+
const timer = setTimeout(() => {
|
|
906
|
+
watchTimers.delete(project.slug);
|
|
907
|
+
void broadcastSnapshot(project);
|
|
908
|
+
}, 50);
|
|
909
|
+
timer.unref?.();
|
|
910
|
+
watchTimers.set(project.slug, timer);
|
|
911
|
+
}
|
|
912
|
+
// Phase 2 keeps deltas simple: recompute the snapshot and diff node ids so the
|
|
913
|
+
// SPA receives node.added/node.updated/node.removed frames, mirroring the
|
|
914
|
+
// cloud broadcast pattern (one payload, fan out to every client for the slug).
|
|
915
|
+
const lastNodes = new Map();
|
|
916
|
+
const lastEdges = new Map();
|
|
917
|
+
const lastMemory = new Map();
|
|
918
|
+
const lastActivity = new Map();
|
|
919
|
+
function edgeKey(edge) {
|
|
920
|
+
return `${edge.from}->${edge.to}:${edge.kind}`;
|
|
921
|
+
}
|
|
922
|
+
// Bus-driven re-broadcast: resolve the slug to a project and emit deltas. Used
|
|
923
|
+
// when a scheduler/merge event fires so connected clients refresh without
|
|
924
|
+
// waiting for the fs.watch debounce.
|
|
925
|
+
async function rebroadcastForSlug(slug) {
|
|
926
|
+
const clients = sseClients.get(slug);
|
|
927
|
+
if (!clients || clients.size === 0) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const project = await findProjectBySlug(slug);
|
|
931
|
+
if (project) {
|
|
932
|
+
await broadcastSnapshot(project);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
async function broadcastSnapshot(project) {
|
|
936
|
+
const clients = sseClients.get(project.slug);
|
|
937
|
+
if (!clients || clients.size === 0) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const snapshot = await buildSnapshot(project);
|
|
941
|
+
const previous = lastNodes.get(project.slug) ?? new Map();
|
|
942
|
+
const current = new Map();
|
|
943
|
+
for (const node of snapshot.nodes) {
|
|
944
|
+
current.set(node.id, node);
|
|
945
|
+
}
|
|
946
|
+
const frames = [];
|
|
947
|
+
for (const [id, node] of current) {
|
|
948
|
+
const prior = previous.get(id);
|
|
949
|
+
if (!prior) {
|
|
950
|
+
frames.push({ event: "node.added", data: node });
|
|
951
|
+
}
|
|
952
|
+
else if (JSON.stringify(prior) !== JSON.stringify(node)) {
|
|
953
|
+
frames.push({ event: "node.updated", data: node });
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
for (const [id] of previous) {
|
|
957
|
+
if (!current.has(id)) {
|
|
958
|
+
frames.push({ event: "node.removed", data: { id } });
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
lastNodes.set(project.slug, current);
|
|
962
|
+
// Edge deltas from graph.json: edge.added / edge.removed for the Nest view.
|
|
963
|
+
const previousEdges = lastEdges.get(project.slug) ?? new Map();
|
|
964
|
+
const currentEdges = new Map();
|
|
965
|
+
for (const edge of snapshot.edges) {
|
|
966
|
+
currentEdges.set(edgeKey(edge), edge);
|
|
967
|
+
}
|
|
968
|
+
for (const [key, edge] of currentEdges) {
|
|
969
|
+
if (!previousEdges.has(key)) {
|
|
970
|
+
frames.push({ event: "edge.added", data: edge });
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
for (const [key, edge] of previousEdges) {
|
|
974
|
+
if (!currentEdges.has(key)) {
|
|
975
|
+
frames.push({ event: "edge.removed", data: { from: edge.from, to: edge.to } });
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
lastEdges.set(project.slug, currentEdges);
|
|
979
|
+
const memoryKey = JSON.stringify(snapshot.memory);
|
|
980
|
+
if (lastMemory.get(project.slug) !== memoryKey) {
|
|
981
|
+
lastMemory.set(project.slug, memoryKey);
|
|
982
|
+
frames.push({ event: "memory.updated", data: { memory: snapshot.memory } });
|
|
983
|
+
}
|
|
984
|
+
const activityKey = JSON.stringify(snapshot.activity ?? []);
|
|
985
|
+
if (lastActivity.get(project.slug) !== activityKey) {
|
|
986
|
+
lastActivity.set(project.slug, activityKey);
|
|
987
|
+
frames.push({ event: "activity.updated", data: { activity: snapshot.activity ?? [] } });
|
|
988
|
+
}
|
|
989
|
+
if (frames.length === 0) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
for (const client of clients) {
|
|
993
|
+
for (const frame of frames) {
|
|
994
|
+
writeSseFrame(client.res, frame.event, frame.data);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
function writeSseFrame(res, event, data) {
|
|
999
|
+
try {
|
|
1000
|
+
res.write(`event: ${event}\n`);
|
|
1001
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
// ignore broken pipe
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// ---------------------------------------------------------------------------
|
|
1008
|
+
// Projection: RunRecord -> BoardNode / BoardSnapshot / ProjectSummary.
|
|
1009
|
+
// ---------------------------------------------------------------------------
|
|
1010
|
+
export function columnForStatus(status) {
|
|
1011
|
+
switch (status) {
|
|
1012
|
+
case "created":
|
|
1013
|
+
return "todo";
|
|
1014
|
+
case "running":
|
|
1015
|
+
case "steering":
|
|
1016
|
+
case "verifying":
|
|
1017
|
+
return "running";
|
|
1018
|
+
case "completed":
|
|
1019
|
+
case "merge-conflict":
|
|
1020
|
+
return "review";
|
|
1021
|
+
case "merged":
|
|
1022
|
+
case "failed":
|
|
1023
|
+
case "cancelled":
|
|
1024
|
+
case "paused":
|
|
1025
|
+
// Orphaned (dashboard closed under a live row) and migrated (handed to a
|
|
1026
|
+
// cloud worker) are terminal-here states; the default "todo" made them
|
|
1027
|
+
// look like un-started work.
|
|
1028
|
+
case "orphaned":
|
|
1029
|
+
case "migrated":
|
|
1030
|
+
return "done";
|
|
1031
|
+
default:
|
|
1032
|
+
return "todo";
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
export function hasVerifiedDelivery(run) {
|
|
1036
|
+
const delivery = run.delivery;
|
|
1037
|
+
return Boolean(delivery?.required &&
|
|
1038
|
+
(delivery.status === "deployed" || delivery.status === "verified") &&
|
|
1039
|
+
delivery.kind?.trim() &&
|
|
1040
|
+
delivery.target?.trim() &&
|
|
1041
|
+
delivery.verifiedAt?.trim() &&
|
|
1042
|
+
delivery.checks?.length);
|
|
1043
|
+
}
|
|
1044
|
+
/** Process completion is terminal for direct/main-checkout work, but a requested
|
|
1045
|
+
* delivery remains in review until its structured target + smoke proof exists. */
|
|
1046
|
+
export function columnForRun(run) {
|
|
1047
|
+
if (run.status === "completed" && !run.workspace.enabled) {
|
|
1048
|
+
return run.delivery?.required && !hasVerifiedDelivery(run) ? "review" : "done";
|
|
1049
|
+
}
|
|
1050
|
+
return columnForStatus(run.status);
|
|
1051
|
+
}
|
|
1052
|
+
function userUpdates(run) {
|
|
1053
|
+
const turns = run.turns ?? [];
|
|
1054
|
+
const start = turns[0]?.prompt === run.task ? 1 : 0;
|
|
1055
|
+
return turns
|
|
1056
|
+
.slice(start)
|
|
1057
|
+
.filter((turn) => turn.source === "user" || turn.source === "regoal")
|
|
1058
|
+
.map((turn) => ({ instruction: turn.prompt, ts: turn.ts, source: turn.source }));
|
|
1059
|
+
}
|
|
1060
|
+
function projectRunToNode(run, lastLine, graphNode) {
|
|
1061
|
+
return {
|
|
1062
|
+
// Keep the graph id stable across planned -> launched so an open issue drawer
|
|
1063
|
+
// does not disappear exactly when the worker starts. Pure runs use run.id.
|
|
1064
|
+
id: graphNode?.id ?? run.id,
|
|
1065
|
+
runId: run.id,
|
|
1066
|
+
title: graphNode?.title || run.taskSummary || run.task,
|
|
1067
|
+
status: graphNode?.status ?? run.status,
|
|
1068
|
+
column: graphNode ? columnForNodeStatus(graphNode.status) : columnForRun(run),
|
|
1069
|
+
blocked: graphNode?.status === "blocked",
|
|
1070
|
+
backend: run.backend,
|
|
1071
|
+
model: run.model,
|
|
1072
|
+
effort: run.effort,
|
|
1073
|
+
lastLine,
|
|
1074
|
+
tokens: run.tokens ?? graphNode?.tokens ?? null,
|
|
1075
|
+
deps: { hard: [], soft: [] },
|
|
1076
|
+
createdAt: run.createdAt,
|
|
1077
|
+
updatedAt: run.updatedAt,
|
|
1078
|
+
workspace: run.workspace
|
|
1079
|
+
? { path: run.workspace.path, workspaceName: run.workspace.workspaceName }
|
|
1080
|
+
: null,
|
|
1081
|
+
merge: run.merge ?? null,
|
|
1082
|
+
delivery: run.delivery,
|
|
1083
|
+
updates: userUpdates(run),
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function columnForNodeStatus(status) {
|
|
1087
|
+
switch (status) {
|
|
1088
|
+
case "planned":
|
|
1089
|
+
case "ready":
|
|
1090
|
+
return "todo";
|
|
1091
|
+
case "running":
|
|
1092
|
+
return "running";
|
|
1093
|
+
case "review":
|
|
1094
|
+
return "review";
|
|
1095
|
+
case "merged":
|
|
1096
|
+
case "failed":
|
|
1097
|
+
return "done";
|
|
1098
|
+
case "blocked":
|
|
1099
|
+
return "todo";
|
|
1100
|
+
default:
|
|
1101
|
+
return "todo";
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
// Project a planner TaskNode (one that has not been scheduled into a run yet)
|
|
1105
|
+
// into a BoardNode. Run-derived nodes stay authoritative for scheduled nodes;
|
|
1106
|
+
// these fill in the not-yet-scheduled DAG. Deps come from incoming edges.
|
|
1107
|
+
function projectTaskNodeToBoardNode(node, deps) {
|
|
1108
|
+
return {
|
|
1109
|
+
id: node.id,
|
|
1110
|
+
...(node.runId ? { runId: node.runId } : {}),
|
|
1111
|
+
title: node.title,
|
|
1112
|
+
// RunStatus and NodeStatus share running/merged/failed; the SPA reads
|
|
1113
|
+
// `column` for layout, so the broad status string is sufficient here.
|
|
1114
|
+
status: node.status,
|
|
1115
|
+
column: columnForNodeStatus(node.status),
|
|
1116
|
+
blocked: node.status === "blocked",
|
|
1117
|
+
backend: node.backend,
|
|
1118
|
+
model: node.model,
|
|
1119
|
+
effort: node.effort,
|
|
1120
|
+
lastLine: node.lastLine ?? null,
|
|
1121
|
+
tokens: node.tokens ?? null,
|
|
1122
|
+
deps,
|
|
1123
|
+
createdAt: node.createdAt,
|
|
1124
|
+
updatedAt: node.updatedAt,
|
|
1125
|
+
workspace: node.workspace ? { path: node.workspace.path, workspaceName: node.workspace.workspaceName } : null,
|
|
1126
|
+
merge: null,
|
|
1127
|
+
updates: [],
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
// Build {from-node-id -> {hard,soft}} maps so each BoardNode carries the ids of
|
|
1131
|
+
// its incoming parents, and the flat BoardEdge list for the Nest/DAG view.
|
|
1132
|
+
function projectGraphEdges(graph) {
|
|
1133
|
+
const edges = [];
|
|
1134
|
+
const depsByNode = new Map();
|
|
1135
|
+
for (const id of Object.keys(graph.nodes)) {
|
|
1136
|
+
depsByNode.set(id, {
|
|
1137
|
+
hard: hardParents(graph, id),
|
|
1138
|
+
soft: softParents(graph, id),
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
for (const edge of Object.values(graph.edges)) {
|
|
1142
|
+
edges.push({ from: edge.from, to: edge.to, kind: edge.type });
|
|
1143
|
+
}
|
|
1144
|
+
return { edges, depsByNode };
|
|
1145
|
+
}
|
|
1146
|
+
async function buildSnapshot(project) {
|
|
1147
|
+
const runs = await listProjectRuns(project.repoRoot);
|
|
1148
|
+
const graph = await readGraph(project.repoRoot);
|
|
1149
|
+
const { edges, depsByNode } = projectGraphEdges(graph);
|
|
1150
|
+
const nodes = [];
|
|
1151
|
+
for (const run of runs) {
|
|
1152
|
+
const lastLine = await lastNonEmptyLine(project.repoRoot, run.id);
|
|
1153
|
+
const graphNode = Object.values(graph.nodes).find((candidate) => candidate.runId === run.id) ??
|
|
1154
|
+
graph.nodes[run.id];
|
|
1155
|
+
const boardNode = projectRunToNode(run, lastLine, graphNode);
|
|
1156
|
+
// If a graph node points at this run (by runId, or by node id), the GRAPH node
|
|
1157
|
+
// is authoritative for scheduled nodes: the daemon writes merged/blocked/review
|
|
1158
|
+
// to graph.json, not run.json. Override the projected column/status/blocked so
|
|
1159
|
+
// merged nodes leave Review and blocked nodes surface. Pure run-derived nodes
|
|
1160
|
+
// (no graph entry) keep their run-projected status untouched.
|
|
1161
|
+
if (graphNode) {
|
|
1162
|
+
boardNode.deps = depsByNode.get(graphNode.id) ?? boardNode.deps;
|
|
1163
|
+
}
|
|
1164
|
+
nodes.push(boardNode);
|
|
1165
|
+
}
|
|
1166
|
+
// Planner nodes that have not been scheduled yet (no runId) fill in the DAG.
|
|
1167
|
+
for (const node of Object.values(graph.nodes)) {
|
|
1168
|
+
if (node.runId) {
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
nodes.push(projectTaskNodeToBoardNode(node, depsByNode.get(node.id) ?? { hard: [], soft: [] }));
|
|
1172
|
+
}
|
|
1173
|
+
const memory = await loadMemory(project.repoRoot);
|
|
1174
|
+
const activity = await loadActivity(project.repoRoot);
|
|
1175
|
+
return {
|
|
1176
|
+
slug: project.slug,
|
|
1177
|
+
name: project.name,
|
|
1178
|
+
generatedAt: new Date().toISOString(),
|
|
1179
|
+
nodes,
|
|
1180
|
+
edges,
|
|
1181
|
+
gates: [],
|
|
1182
|
+
memory,
|
|
1183
|
+
activity,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
async function buildProjectSummary(project) {
|
|
1187
|
+
const runs = await listProjectRuns(project.repoRoot);
|
|
1188
|
+
const counts = { todo: 0, running: 0, review: 0, done: 0, blocked: 0, failed: 0 };
|
|
1189
|
+
let lastActivityAt = "";
|
|
1190
|
+
for (const run of runs) {
|
|
1191
|
+
// Count each run exactly once so the per-status tallies sum to the node total.
|
|
1192
|
+
// failed/cancelled get their own bucket and do NOT also fall into `done`
|
|
1193
|
+
// (columnForStatus maps both to "done", which would double-count failed).
|
|
1194
|
+
if (run.status === "failed" || run.status === "cancelled") {
|
|
1195
|
+
counts.failed += 1;
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
counts[columnForRun(run)] += 1;
|
|
1199
|
+
}
|
|
1200
|
+
const stamp = run.updatedAt || run.createdAt;
|
|
1201
|
+
if (stamp && stamp > lastActivityAt) {
|
|
1202
|
+
lastActivityAt = stamp;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return {
|
|
1206
|
+
slug: project.slug,
|
|
1207
|
+
name: project.name,
|
|
1208
|
+
repoRoot: project.repoRoot,
|
|
1209
|
+
counts,
|
|
1210
|
+
lastActivityAt: lastActivityAt || new Date(0).toISOString(),
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
async function listProjectRuns(repoRoot) {
|
|
1214
|
+
const dir = runsDir(repoRoot);
|
|
1215
|
+
let entries;
|
|
1216
|
+
try {
|
|
1217
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
return [];
|
|
1221
|
+
}
|
|
1222
|
+
const runs = await Promise.all(entries
|
|
1223
|
+
.filter((entry) => entry.isDirectory())
|
|
1224
|
+
.map((entry) => loadRunRecord(repoRoot, entry.name)));
|
|
1225
|
+
return runs
|
|
1226
|
+
.filter((run) => Boolean(run))
|
|
1227
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1228
|
+
}
|
|
1229
|
+
async function lastNonEmptyLine(repoRoot, runId) {
|
|
1230
|
+
const text = await readOutput(repoRoot, runId);
|
|
1231
|
+
if (!text) {
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
const lines = text.split(/\r?\n/);
|
|
1235
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
1236
|
+
const trimmed = lines[i]?.trim();
|
|
1237
|
+
if (trimmed) {
|
|
1238
|
+
return trimmed.length > 500 ? trimmed.slice(0, 500) : trimmed;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
async function readOutput(repoRoot, runId) {
|
|
1244
|
+
try {
|
|
1245
|
+
return await fsp.readFile(outputPath(repoRoot, runId), "utf8");
|
|
1246
|
+
}
|
|
1247
|
+
catch {
|
|
1248
|
+
return "";
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
async function readLogTail(repoRoot, runId, tail) {
|
|
1252
|
+
const text = await readOutput(repoRoot, runId);
|
|
1253
|
+
if (!text) {
|
|
1254
|
+
return "";
|
|
1255
|
+
}
|
|
1256
|
+
const lines = text.split(/\r?\n/);
|
|
1257
|
+
// A trailing newline yields an empty final element; drop it so tail=N counts
|
|
1258
|
+
// real lines rather than spending a slot on the blank line.
|
|
1259
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
1260
|
+
lines.pop();
|
|
1261
|
+
}
|
|
1262
|
+
const count = Math.max(1, tail);
|
|
1263
|
+
return lines.slice(-count).join("\n");
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Memory view: parse DECISIONS.md (repo root) into entries. The current format is one
|
|
1267
|
+
* `## title` block per decision, with `- **What:**` / `- **Did:**` / `- **Decision:**`
|
|
1268
|
+
* body lines and a `- **By:** owner · <iso>` footer; each block becomes one MemoryEntry
|
|
1269
|
+
* (text from the What/Did/Decision line or the title, owner+ts from By). Legacy top-level
|
|
1270
|
+
* `-`/`*` bullets with a trailing "(owner: X, <iso>)" suffix are still parsed for back
|
|
1271
|
+
* compat. Absent file yields [].
|
|
1272
|
+
*/
|
|
1273
|
+
export function parseDecisions(raw) {
|
|
1274
|
+
const entries = [];
|
|
1275
|
+
const lines = raw.split(/\r?\n/);
|
|
1276
|
+
let i = 0;
|
|
1277
|
+
while (i < lines.length) {
|
|
1278
|
+
const heading = lines[i].match(/^##\s+(.*)$/);
|
|
1279
|
+
if (heading) {
|
|
1280
|
+
const title = heading[1].trim();
|
|
1281
|
+
let text;
|
|
1282
|
+
let owner;
|
|
1283
|
+
let ts;
|
|
1284
|
+
i++;
|
|
1285
|
+
// Gather the block until the next `## ` heading (or EOF).
|
|
1286
|
+
while (i < lines.length && !/^##\s+/.test(lines[i])) {
|
|
1287
|
+
const field = lines[i].trim().match(/^[-*]\s+\*\*([^:*]+):\*\*\s*(.*)$/);
|
|
1288
|
+
if (field) {
|
|
1289
|
+
const label = field[1].trim().toLowerCase();
|
|
1290
|
+
const value = field[2].trim();
|
|
1291
|
+
if (label === "by") {
|
|
1292
|
+
const by = value.match(/^(.*?)\s*·\s*(.*)$/);
|
|
1293
|
+
owner = (by ? by[1] : value).trim() || undefined;
|
|
1294
|
+
ts = by?.[2]?.trim() || undefined;
|
|
1295
|
+
// Normalize an epoch-ms stamp (the conductor's now_stamp) to ISO so the board
|
|
1296
|
+
// shows one consistent format alongside the worker/CLI ISO stamps.
|
|
1297
|
+
if (ts && /^\d{13}$/.test(ts)) {
|
|
1298
|
+
const iso = new Date(Number(ts)).toISOString();
|
|
1299
|
+
if (!Number.isNaN(Date.parse(iso))) {
|
|
1300
|
+
ts = iso;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
else if (text === undefined &&
|
|
1305
|
+
(label === "what" || label === "did" || label === "decision")) {
|
|
1306
|
+
text = value;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
i++;
|
|
1310
|
+
}
|
|
1311
|
+
entries.push({
|
|
1312
|
+
text: text || title,
|
|
1313
|
+
...(owner ? { owner } : {}),
|
|
1314
|
+
...(ts ? { ts } : {}),
|
|
1315
|
+
});
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
// Legacy: a top-level "-"/"*" bullet (optionally with an "(owner: X, <iso>)" suffix).
|
|
1319
|
+
const bullet = lines[i].match(/^\s*[-*]\s+(.*)$/);
|
|
1320
|
+
const body = bullet?.[1]?.trim();
|
|
1321
|
+
if (body) {
|
|
1322
|
+
entries.push(parseDecisionBullet(body));
|
|
1323
|
+
}
|
|
1324
|
+
i++;
|
|
1325
|
+
}
|
|
1326
|
+
return entries;
|
|
1327
|
+
}
|
|
1328
|
+
// Parse one bullet body. The optional "(owner: X, <iso>)" suffix is stripped off
|
|
1329
|
+
// the text and split into owner + ts. A bullet may carry just "(owner: X)" or a
|
|
1330
|
+
// bare "(<iso>)"; anything unrecognized stays part of the text.
|
|
1331
|
+
function parseDecisionBullet(body) {
|
|
1332
|
+
const suffix = body.match(/\s*\(owner:\s*([^,)]+?)(?:,\s*([^)]+?))?\)\s*$/i);
|
|
1333
|
+
if (suffix) {
|
|
1334
|
+
const text = body.slice(0, suffix.index).trim();
|
|
1335
|
+
const owner = suffix[1]?.trim();
|
|
1336
|
+
const ts = suffix[2]?.trim();
|
|
1337
|
+
return {
|
|
1338
|
+
text: text || body,
|
|
1339
|
+
...(owner ? { owner } : {}),
|
|
1340
|
+
...(ts ? { ts } : {}),
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
return { text: body };
|
|
1344
|
+
}
|
|
1345
|
+
async function loadMemory(repoRoot) {
|
|
1346
|
+
let raw;
|
|
1347
|
+
try {
|
|
1348
|
+
raw = await fsp.readFile(path.join(repoRoot, "DECISIONS.md"), "utf8");
|
|
1349
|
+
}
|
|
1350
|
+
catch {
|
|
1351
|
+
return [];
|
|
1352
|
+
}
|
|
1353
|
+
return parseDecisions(raw);
|
|
1354
|
+
}
|
|
1355
|
+
// Activity feed: the live narration stream the native TUI appends to
|
|
1356
|
+
// .rudder/activity.jsonl (conductor actions, steer confirmations, periodic
|
|
1357
|
+
// heartbeats). We tail the last N lines so a long session stays cheap, parse
|
|
1358
|
+
// each JSON line, and normalize the ms timestamp to ISO for the UI.
|
|
1359
|
+
const ACTIVITY_TAIL = 120;
|
|
1360
|
+
export function parseActivityJsonl(raw) {
|
|
1361
|
+
const out = [];
|
|
1362
|
+
const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
1363
|
+
for (const line of lines.slice(-ACTIVITY_TAIL)) {
|
|
1364
|
+
try {
|
|
1365
|
+
const parsed = JSON.parse(line);
|
|
1366
|
+
const text = typeof parsed.text === "string" ? parsed.text : "";
|
|
1367
|
+
if (!text) {
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
let ts;
|
|
1371
|
+
if (typeof parsed.ts === "string" && parsed.ts) {
|
|
1372
|
+
ts = /^\d{10,}$/.test(parsed.ts) ? new Date(Number(parsed.ts)).toISOString() : parsed.ts;
|
|
1373
|
+
}
|
|
1374
|
+
else if (typeof parsed.ts === "number") {
|
|
1375
|
+
ts = new Date(parsed.ts).toISOString();
|
|
1376
|
+
}
|
|
1377
|
+
const kind = parsed.kind === "heartbeat" ? "heartbeat" : "action";
|
|
1378
|
+
out.push({ text, kind, ...(ts ? { ts } : {}) });
|
|
1379
|
+
}
|
|
1380
|
+
catch {
|
|
1381
|
+
// skip a malformed line; the stream is best-effort
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return out;
|
|
1385
|
+
}
|
|
1386
|
+
async function loadActivity(repoRoot) {
|
|
1387
|
+
let raw;
|
|
1388
|
+
try {
|
|
1389
|
+
raw = await fsp.readFile(path.join(projectStateDir(repoRoot), "activity.jsonl"), "utf8");
|
|
1390
|
+
}
|
|
1391
|
+
catch {
|
|
1392
|
+
return [];
|
|
1393
|
+
}
|
|
1394
|
+
return parseActivityJsonl(raw);
|
|
1395
|
+
}
|
|
1396
|
+
// ---------------------------------------------------------------------------
|
|
1397
|
+
// HTML shell. The SPA owns all CSS; this carries no inline styles.
|
|
1398
|
+
// ---------------------------------------------------------------------------
|
|
1399
|
+
function renderShell(slug, controlMode, canMutate, view = "board") {
|
|
1400
|
+
const slugJson = JSON.stringify(slug ?? "");
|
|
1401
|
+
return `<!doctype html>
|
|
1402
|
+
<html lang="en">
|
|
1403
|
+
<head>
|
|
1404
|
+
<meta charset="utf-8" />
|
|
1405
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1406
|
+
<title>${view === "usage" ? "rudder · usage" : "rudder"}</title>
|
|
1407
|
+
<link rel="stylesheet" href="/board.css" />
|
|
1408
|
+
</head>
|
|
1409
|
+
<body>
|
|
1410
|
+
<div id="app"></div>
|
|
1411
|
+
<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)}; window.__RUDDER_VIEW__ = ${JSON.stringify(view)}</script>
|
|
1412
|
+
<script type="module" src="/board.js"></script>
|
|
1413
|
+
</body>
|
|
1414
|
+
</html>
|
|
1415
|
+
`;
|
|
1416
|
+
}
|
|
1417
|
+
function sameRepoPath(left, right) {
|
|
1418
|
+
const canonical = (value) => {
|
|
1419
|
+
const resolved = path.resolve(value);
|
|
1420
|
+
try {
|
|
1421
|
+
return fs.realpathSync(resolved);
|
|
1422
|
+
}
|
|
1423
|
+
catch {
|
|
1424
|
+
return resolved;
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
return canonical(left) === canonical(right);
|
|
1428
|
+
}
|
|
1429
|
+
// ---------------------------------------------------------------------------
|
|
1430
|
+
// Helpers.
|
|
1431
|
+
// ---------------------------------------------------------------------------
|
|
1432
|
+
/**
|
|
1433
|
+
* Run a callback with the process cwd pinned to repoRoot, so run-manager and jj
|
|
1434
|
+
* functions that resolve the repo from cwd act on the right project. The board
|
|
1435
|
+
* serializes mutating routes implicitly via this chdir swap.
|
|
1436
|
+
*/
|
|
1437
|
+
let repoChdirLock = Promise.resolve();
|
|
1438
|
+
function inRepo(repoRoot, fn) {
|
|
1439
|
+
const run = repoChdirLock.then(async () => {
|
|
1440
|
+
const previous = process.cwd();
|
|
1441
|
+
process.chdir(repoRoot);
|
|
1442
|
+
try {
|
|
1443
|
+
return await fn();
|
|
1444
|
+
}
|
|
1445
|
+
finally {
|
|
1446
|
+
try {
|
|
1447
|
+
process.chdir(previous);
|
|
1448
|
+
}
|
|
1449
|
+
catch {
|
|
1450
|
+
// ignore
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
repoChdirLock = run.then(() => undefined, () => undefined);
|
|
1455
|
+
return run;
|
|
1456
|
+
}
|
|
1457
|
+
async function readJsonBody(req) {
|
|
1458
|
+
const chunks = [];
|
|
1459
|
+
let total = 0;
|
|
1460
|
+
for await (const chunk of req) {
|
|
1461
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1462
|
+
total += buf.length;
|
|
1463
|
+
if (total > MAX_BODY_BYTES) {
|
|
1464
|
+
// Oversized body: stop reading and treat as empty (DoS guard).
|
|
1465
|
+
req.destroy();
|
|
1466
|
+
return {};
|
|
1467
|
+
}
|
|
1468
|
+
chunks.push(buf);
|
|
1469
|
+
}
|
|
1470
|
+
if (chunks.length === 0) {
|
|
1471
|
+
return {};
|
|
1472
|
+
}
|
|
1473
|
+
try {
|
|
1474
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1475
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1476
|
+
? parsed
|
|
1477
|
+
: {};
|
|
1478
|
+
}
|
|
1479
|
+
catch {
|
|
1480
|
+
return {};
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
async function sendStatic(res, filePath, contentType) {
|
|
1484
|
+
try {
|
|
1485
|
+
const data = await fsp.readFile(filePath);
|
|
1486
|
+
res.writeHead(200, { "content-type": contentType });
|
|
1487
|
+
res.end(data);
|
|
1488
|
+
}
|
|
1489
|
+
catch {
|
|
1490
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1491
|
+
res.end("bundle not built");
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
function sendJson(res, status, body) {
|
|
1495
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
1496
|
+
res.end(JSON.stringify(body));
|
|
1497
|
+
}
|
|
1498
|
+
function sendHtml(res, body, status = 200) {
|
|
1499
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
1500
|
+
res.end(body);
|
|
1501
|
+
}
|
|
1502
|
+
function openBrowser(url) {
|
|
1503
|
+
const platform = process.platform;
|
|
1504
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
1505
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1506
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
1507
|
+
child.on("error", () => undefined);
|
|
1508
|
+
child.unref();
|
|
1509
|
+
}
|
|
1510
|
+
//# sourceMappingURL=daemon.js.map
|