faberun 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { discoverCampaigns } from "../campaign/index.mjs";
|
|
8
|
+
import { campaignDir, campaignsDir } from "../campaign/layout.mjs";
|
|
9
|
+
import { errorMessage, readJsonTolerant } from "../util.mjs";
|
|
10
|
+
import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
|
|
11
|
+
import { assertPrivateBind, loadBearerToken, resolveBindAddress } from "./boundary.mjs";
|
|
12
|
+
import { handleApiRequest } from "./api.mjs";
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const STREAM_POLL_MS = 700;
|
|
16
|
+
const STREAM_PING_MS = 15_000;
|
|
17
|
+
const SNAPSHOT_MAX_BYTES = 200 * 1024;
|
|
18
|
+
const LOG_TAIL_MAX_LINES = 200;
|
|
19
|
+
const LOG_TAIL_MAX_BYTES = 24 * 1024;
|
|
20
|
+
const OUTPUT_TAIL_MAX_BYTES = 4 * 1024;
|
|
21
|
+
const PROMPT_MAX_CHARS = 8 * 1024;
|
|
22
|
+
const HANDOFF_MAX_CHARS = 16 * 1024;
|
|
23
|
+
const DIFF_PATH_CAP = 400;
|
|
24
|
+
const GOAL_MAX_CHARS = 200;
|
|
25
|
+
const TERMINAL_STATUSES = new Set(["done", "no-op", "failed", "blocked", "exhausted", "stalled", "canceled", "cancelled"]);
|
|
26
|
+
const RESUME_STATES = new Set(["blocked", "failed", "exhausted", "stalled", "canceled", "cancelled"]);
|
|
27
|
+
|
|
28
|
+
/** @typedef {{campaignId?: string|null, runId?: string|null, nodeId?: string|null}} Selection */
|
|
29
|
+
/** @typedef {{path: string, campaign: import("../campaign/index.mjs").Campaign}} CampaignEntry */
|
|
30
|
+
|
|
31
|
+
/** Last complete JSONL entries of an append-only file; a torn trailing line is dropped, not fatal. @param {string} path @param {number} maxEntries @param {number} [maxBytes] @returns {Record<string, unknown>[]} */
|
|
32
|
+
export function tailJsonl(path, maxEntries, maxBytes = 32 * 1024) {
|
|
33
|
+
const entries = [];
|
|
34
|
+
for (const line of linesInWindow(path, maxBytes)) {
|
|
35
|
+
try {
|
|
36
|
+
entries.push(/** @type {Record<string, unknown>} */ (JSON.parse(line)));
|
|
37
|
+
} catch {
|
|
38
|
+
// dropped: torn by a concurrent append
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return entries.slice(-maxEntries);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Last complete text lines of a file, read from the end without loading the whole file. @param {string} path @param {number} maxLines @param {number} maxBytes @returns {string[]} */
|
|
45
|
+
function tailTextLines(path, maxLines, maxBytes) {
|
|
46
|
+
return linesInWindow(path, maxBytes).slice(-maxLines);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every complete, non-blank line within the trailing `maxBytes` window of a file. @param {string} path @param {number} maxBytes @returns {string[]} */
|
|
50
|
+
function linesInWindow(path, maxBytes) {
|
|
51
|
+
if (!existsSync(path)) return [];
|
|
52
|
+
const stat = statSync(path);
|
|
53
|
+
const readLength = Math.min(stat.size, maxBytes);
|
|
54
|
+
const bytes = readLength > 0 ? readWindow(path, readLength, stat.size - readLength) : Buffer.alloc(0);
|
|
55
|
+
let text = bytes.toString("utf8");
|
|
56
|
+
if (stat.size > maxBytes) text = text.slice(text.indexOf("\n") + 1);
|
|
57
|
+
return text.split("\n").filter((line) => line.trim().length > 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {string} path @param {number} length @param {number} position @returns {Buffer} */
|
|
61
|
+
function readWindow(path, length, position) {
|
|
62
|
+
const fd = openSync(path, "r");
|
|
63
|
+
try {
|
|
64
|
+
const buffer = Buffer.alloc(length);
|
|
65
|
+
const filled = readSync(fd, buffer, 0, length, position);
|
|
66
|
+
return buffer.subarray(0, filled);
|
|
67
|
+
} finally {
|
|
68
|
+
closeSync(fd);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {string} value @param {number} maxChars @returns {string} */
|
|
73
|
+
function truncateChars(value, maxChars) {
|
|
74
|
+
const chars = Array.from(value);
|
|
75
|
+
return chars.length <= maxChars ? value : `${chars.slice(0, maxChars).join("")}…`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {string} value @returns {string} */
|
|
79
|
+
function oneLine(value) {
|
|
80
|
+
return truncateChars(String(value ?? "").replace(/\s+/gu, " ").trim(), GOAL_MAX_CHARS);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {string|null|undefined} startIso @param {string|null|undefined} endIso @returns {number|null} */
|
|
84
|
+
function elapsedMsBetween(startIso, endIso) {
|
|
85
|
+
const start = Date.parse(startIso ?? "");
|
|
86
|
+
if (!Number.isFinite(start)) return null;
|
|
87
|
+
const end = endIso ? Date.parse(endIso) : Date.now();
|
|
88
|
+
return Number.isFinite(end) ? Math.max(0, end - start) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {string[]} paths @returns {string} */
|
|
92
|
+
function fileSignature(paths) {
|
|
93
|
+
return paths.map((path) => {
|
|
94
|
+
try {
|
|
95
|
+
const stat = statSync(path);
|
|
96
|
+
return `${path}:${stat.mtimeMs}:${stat.size}`;
|
|
97
|
+
} catch {
|
|
98
|
+
return `${path}:-`;
|
|
99
|
+
}
|
|
100
|
+
}).join("|");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {string} runDir @returns {Record<string, any>|null} */
|
|
104
|
+
function readRunStatus(runDir) {
|
|
105
|
+
const status = readJsonTolerant(join(runDir, "status.json"));
|
|
106
|
+
return status && typeof status === "object" && Array.isArray(/** @type {any} */ (status).nodes) ? /** @type {any} */ (status) : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {string} runDir @returns {string|null} */
|
|
110
|
+
function lastEventAt(runDir) {
|
|
111
|
+
const tail = tailJsonl(join(runDir, "events.jsonl"), 1);
|
|
112
|
+
return typeof tail[0]?.at === "string" ? /** @type {string} */ (tail[0].at) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The runs-table row for one run, read from `status.json` and `run.json`; unreadable is listed flagged `corrupt`, never thrown. @param {string} runsDir @param {string} runId @returns {Record<string, unknown>} */
|
|
116
|
+
function runSummary(runsDir, runId) {
|
|
117
|
+
const runDir = join(runsDir, runId);
|
|
118
|
+
const status = readRunStatus(runDir);
|
|
119
|
+
if (!status) return { id: runId, corrupt: "status.json missing or unparsable" };
|
|
120
|
+
const meta = /** @type {{startedAt?: unknown}|null} */ (readJsonTolerant(join(runDir, "run.json")));
|
|
121
|
+
const nodes = /** @type {Record<string, any>[]} */ (status.nodes);
|
|
122
|
+
const nodesDone = nodes.filter((node) => node.status === "done" || node.status === "no-op").length;
|
|
123
|
+
const attempts = nodes.reduce((max, node) => Math.max(max, node.attempt ?? 0), 0);
|
|
124
|
+
const terminal = nodes.length > 0 && nodes.every((node) => TERMINAL_STATUSES.has(node.status));
|
|
125
|
+
const attentionNode = nodes.find((node) => RESUME_STATES.has(node.status));
|
|
126
|
+
const startedAt = typeof meta?.startedAt === "string" ? meta.startedAt : null;
|
|
127
|
+
const updatedAt = lastEventAt(runDir) ?? startedAt;
|
|
128
|
+
return {
|
|
129
|
+
id: runId,
|
|
130
|
+
corrupt: null,
|
|
131
|
+
campaignId: typeof status.campaignId === "string" ? status.campaignId : null,
|
|
132
|
+
goal: oneLine(typeof status.goal === "string" ? status.goal : ""),
|
|
133
|
+
nodesDone,
|
|
134
|
+
nodesTotal: nodes.length,
|
|
135
|
+
attempts,
|
|
136
|
+
elapsedMs: elapsedMsBetween(startedAt, terminal ? updatedAt : null),
|
|
137
|
+
costUsd: typeof status.usage?.costUsd === "number" ? status.usage.costUsd : null,
|
|
138
|
+
startedAt,
|
|
139
|
+
updatedAt,
|
|
140
|
+
controllerActive: status.controller?.state === "active",
|
|
141
|
+
state: attentionNode ? "attention" : terminal ? "done" : "active",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** "Needs you" items across a campaign's linked runs: one per attention-state node plus one per orphaned running node, each carrying the exact command that resolves it. @param {string} runsDir @param {string[]} runIds @returns {Record<string, unknown>[]} */
|
|
146
|
+
function needsYouItems(runsDir, runIds) {
|
|
147
|
+
const items = [];
|
|
148
|
+
for (const runId of runIds) {
|
|
149
|
+
const runDir = join(runsDir, runId);
|
|
150
|
+
const status = readRunStatus(runDir);
|
|
151
|
+
if (!status) continue;
|
|
152
|
+
const controllerActive = status.controller?.state === "active";
|
|
153
|
+
for (const node of /** @type {Record<string, any>[]} */ (status.nodes)) {
|
|
154
|
+
if (RESUME_STATES.has(node.status)) {
|
|
155
|
+
const reconcile = node.errorCode === "unknown_effect_reconciled";
|
|
156
|
+
items.push({
|
|
157
|
+
runId,
|
|
158
|
+
nodeId: node.id,
|
|
159
|
+
status: node.status,
|
|
160
|
+
errorCode: node.errorCode ?? null,
|
|
161
|
+
note: node.note ?? null,
|
|
162
|
+
command: reconcile ? `resume ${runDir} --reconcile ${node.id}` : `resume ${runDir}`,
|
|
163
|
+
});
|
|
164
|
+
} else if (node.status === "running" && !controllerActive) {
|
|
165
|
+
items.push({
|
|
166
|
+
runId,
|
|
167
|
+
nodeId: node.id,
|
|
168
|
+
status: "orphaned",
|
|
169
|
+
errorCode: null,
|
|
170
|
+
note: "the controller process is gone while this node still claims to be running",
|
|
171
|
+
command: `resume ${runDir}`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return items;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The "now" strip: the run with a live controller, or the most recently updated run when the campaign is idle. @param {string} runsDir @param {Record<string, any>[]} runs @returns {Record<string, unknown>} */
|
|
180
|
+
function nowStrip(runsDir, runs) {
|
|
181
|
+
const readable = runs.filter((run) => !/** @type {any} */ (run).corrupt);
|
|
182
|
+
const active = /** @type {any} */ (readable.find((run) => /** @type {any} */ (run).controllerActive));
|
|
183
|
+
if (active) {
|
|
184
|
+
const runDir = join(runsDir, active.id);
|
|
185
|
+
const status = readRunStatus(runDir);
|
|
186
|
+
const runningNode = status?.nodes.find((/** @type {any} */ node) => node.status === "running") ?? null;
|
|
187
|
+
const startedAt = runningNode ? /** @type {any} */ (readJsonTolerant(nodeSnapshotPath(runDir, runningNode.id)))?.startedAt ?? null : null;
|
|
188
|
+
return {
|
|
189
|
+
state: "active",
|
|
190
|
+
runId: active.id,
|
|
191
|
+
nodeId: runningNode?.id ?? null,
|
|
192
|
+
elapsedMs: startedAt ? elapsedMsBetween(startedAt, null) : null,
|
|
193
|
+
costUsd: active.costUsd,
|
|
194
|
+
updatedAt: active.updatedAt,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const idle = readable.slice().sort((left, right) => String(/** @type {any} */ (right).updatedAt ?? "").localeCompare(String(/** @type {any} */ (left).updatedAt ?? "")))[0];
|
|
198
|
+
return { state: "idle", runId: idle ? /** @type {any} */ (idle).id : null, updatedAt: idle ? /** @type {any} */ (idle).updatedAt : null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** @param {Record<string, any>|null|undefined} verification */
|
|
202
|
+
function verificationTab(verification) {
|
|
203
|
+
if (!verification || !Array.isArray(verification.commands)) return null;
|
|
204
|
+
return {
|
|
205
|
+
passed: verification.passed ?? null,
|
|
206
|
+
commands: verification.commands.map((command) => {
|
|
207
|
+
const last = Array.isArray(command.attempts) ? command.attempts.at(-1) : null;
|
|
208
|
+
return {
|
|
209
|
+
command: Array.isArray(command.argv) ? command.argv.join(" ") : "",
|
|
210
|
+
passed: command.passed ?? last?.passed ?? null,
|
|
211
|
+
durationMs: last?.durationMs ?? null,
|
|
212
|
+
outputTail: truncateChars(`${last?.stdout ?? ""}${last?.stderr ? `\n${last.stderr}` : ""}`.trim(), OUTPUT_TAIL_MAX_BYTES),
|
|
213
|
+
};
|
|
214
|
+
}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @param {Record<string, any>|null|undefined} scope */
|
|
219
|
+
function diffTab(scope) {
|
|
220
|
+
if (!scope) return null;
|
|
221
|
+
const changed = Array.isArray(scope.changedPaths) ? scope.changedPaths : [];
|
|
222
|
+
const unexpected = Array.isArray(scope.unexpectedPaths) ? scope.unexpectedPaths : [];
|
|
223
|
+
const count = scope.changedPathCount ?? changed.length;
|
|
224
|
+
return {
|
|
225
|
+
stat: `${count} file${count === 1 ? "" : "s"} changed${unexpected.length ? ` · ${unexpected.length} unexpected` : ""}`,
|
|
226
|
+
files: changed.slice(0, DIFF_PATH_CAP),
|
|
227
|
+
unexpected: unexpected.slice(0, DIFF_PATH_CAP),
|
|
228
|
+
truncated: changed.length > DIFF_PATH_CAP,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @param {Record<string, any>|null|undefined} gate */
|
|
233
|
+
function findingsTab(gate) {
|
|
234
|
+
if (!gate) return null;
|
|
235
|
+
return {
|
|
236
|
+
verdict: gate.verdict ?? null,
|
|
237
|
+
maxSeverity: gate.maxSeverity ?? null,
|
|
238
|
+
summary: gate.summary ?? null,
|
|
239
|
+
findings: (Array.isArray(gate.findings) ? gate.findings : []).map((finding) => ({
|
|
240
|
+
severity: finding.severity,
|
|
241
|
+
description: finding.description,
|
|
242
|
+
evidence: truncateChars(finding.evidence ?? "", 1024),
|
|
243
|
+
})),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @param {Record<string, any>[]} invocations @returns {Record<string, any>|undefined} */
|
|
248
|
+
function lastWorkerInvocation(invocations) {
|
|
249
|
+
return [...(Array.isArray(invocations) ? invocations : [])].reverse().find((invocation) => invocation.role === "worker");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @param {string} runDir @param {Record<string, any>} node @returns {{lines: string[], path: string|null}} */
|
|
253
|
+
function workerLogTab(runDir, node) {
|
|
254
|
+
const worker = lastWorkerInvocation(node.invocations);
|
|
255
|
+
if (!worker?.stdoutPath) return { lines: [], path: null };
|
|
256
|
+
return { lines: tailTextLines(worker.stdoutPath, LOG_TAIL_MAX_LINES, LOG_TAIL_MAX_BYTES), path: basename(worker.stdoutPath) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @param {Record<string, any>} node @returns {string|null} */
|
|
260
|
+
function promptTab(node) {
|
|
261
|
+
const worker = lastWorkerInvocation(node.invocations);
|
|
262
|
+
if (!worker?.promptPath || !existsSync(worker.promptPath)) return null;
|
|
263
|
+
try {
|
|
264
|
+
return truncateChars(readFileSync(worker.promptPath, "utf8"), PROMPT_MAX_CHARS);
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The five drawer tabs for one node. @param {string} runDir @param {string} nodeId @returns {Record<string, unknown>|null} */
|
|
271
|
+
function nodeDetail(runDir, nodeId) {
|
|
272
|
+
const node = readJsonTolerant(nodeSnapshotPath(runDir, nodeId));
|
|
273
|
+
if (!node || typeof node !== "object") return null;
|
|
274
|
+
const record = /** @type {Record<string, any>} */ (node);
|
|
275
|
+
return {
|
|
276
|
+
id: nodeId,
|
|
277
|
+
log: workerLogTab(runDir, record),
|
|
278
|
+
verification: verificationTab(record.verification),
|
|
279
|
+
diff: diffTab(record.scope),
|
|
280
|
+
findings: findingsTab(record.gate),
|
|
281
|
+
prompt: promptTab(record),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The drawer's node-row summary: `status.json`'s node entry merged with the node JSON's own fields. @param {string} runDir @param {Record<string, any>} statusNode @returns {Record<string, unknown>} */
|
|
286
|
+
function nodeRow(runDir, statusNode) {
|
|
287
|
+
const node = /** @type {Record<string, any>} */ (readJsonTolerant(nodeSnapshotPath(runDir, statusNode.id)) ?? {});
|
|
288
|
+
const closed = TERMINAL_STATUSES.has(statusNode.status);
|
|
289
|
+
return {
|
|
290
|
+
id: statusNode.id,
|
|
291
|
+
status: statusNode.status,
|
|
292
|
+
attempt: statusNode.attempt,
|
|
293
|
+
revisions: statusNode.revisions,
|
|
294
|
+
model: node.runtime?.model ?? null,
|
|
295
|
+
elapsedMs: elapsedMsBetween(node.startedAt, closed ? node.updatedAt : null),
|
|
296
|
+
costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
|
|
297
|
+
verdict: node.gate?.verdict ?? null,
|
|
298
|
+
note: statusNode.note ?? null,
|
|
299
|
+
errorCode: statusNode.errorCode ?? null,
|
|
300
|
+
blockedBy: statusNode.blockedBy ?? [],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** @param {string} runsDir @param {string} runId @param {string|null} nodeId @returns {Record<string, unknown>|null} */
|
|
305
|
+
function runDrawer(runsDir, runId, nodeId) {
|
|
306
|
+
const runDir = join(runsDir, runId);
|
|
307
|
+
const status = readRunStatus(runDir);
|
|
308
|
+
if (!status) return null;
|
|
309
|
+
const nodes = /** @type {Record<string, any>[]} */ (status.nodes).map((node) => nodeRow(runDir, node));
|
|
310
|
+
const selectedNodeId = nodeId && nodes.some((node) => node.id === nodeId) ? nodeId : null;
|
|
311
|
+
return {
|
|
312
|
+
runId,
|
|
313
|
+
goal: typeof status.goal === "string" ? status.goal : "",
|
|
314
|
+
campaignId: status.campaignId ?? null,
|
|
315
|
+
nodes,
|
|
316
|
+
selectedNodeId,
|
|
317
|
+
detail: selectedNodeId ? nodeDetail(runDir, selectedNodeId) : null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** @param {string} campaignPath @returns {string|null} */
|
|
322
|
+
function readHandoff(campaignPath) {
|
|
323
|
+
const path = join(campaignPath, "HANDOFF.md");
|
|
324
|
+
if (!existsSync(path)) return null;
|
|
325
|
+
try {
|
|
326
|
+
return truncateChars(readFileSync(path, "utf8"), HANDOFF_MAX_CHARS);
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Shrink the payload, in order, until it fits the 200 KB ceiling: the open drawer's log tail, its verification output tails, its prompt, then the handoff text. @param {Record<string, any>} payload @returns {Record<string, unknown>} */
|
|
333
|
+
function boundSnapshot(payload) {
|
|
334
|
+
const fits = () => Buffer.byteLength(JSON.stringify(payload), "utf8") <= SNAPSHOT_MAX_BYTES;
|
|
335
|
+
if (fits()) return payload;
|
|
336
|
+
const detail = payload.drawer?.detail;
|
|
337
|
+
if (detail?.log?.lines) detail.log.lines = detail.log.lines.slice(-20);
|
|
338
|
+
if (fits()) return payload;
|
|
339
|
+
if (detail?.verification?.commands) detail.verification.commands = detail.verification.commands.map((/** @type {any} */ command) => ({ ...command, outputTail: truncateChars(command.outputTail, 200) }));
|
|
340
|
+
if (fits()) return payload;
|
|
341
|
+
if (detail?.prompt) detail.prompt = truncateChars(detail.prompt, 500);
|
|
342
|
+
if (fits()) return payload;
|
|
343
|
+
if (payload.handoff) payload.handoff = truncateChars(payload.handoff, 500);
|
|
344
|
+
if (fits()) return payload;
|
|
345
|
+
payload.runs = payload.runs.slice(0, 20);
|
|
346
|
+
return payload;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The declared campaign id when it exists, else the active campaign, else the first one. @param {CampaignEntry[]} campaigns @param {string|null|undefined} campaignId @returns {CampaignEntry|null} */
|
|
350
|
+
function resolveCampaign(campaigns, campaignId) {
|
|
351
|
+
if (campaignId) {
|
|
352
|
+
const declared = campaigns.find(({ campaign }) => campaign.id === campaignId);
|
|
353
|
+
if (declared) return declared;
|
|
354
|
+
}
|
|
355
|
+
return campaigns.find(({ campaign }) => campaign.status === "active") ?? campaigns[0] ?? null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The full page snapshot: campaign list, the selected campaign's now strip, needs-you items, runs table and (when open) the drawer detail and handoff text. Read-only, bounded to ~200 KB. @param {string} runsDir @param {Selection} [selection] @returns {Record<string, unknown>} */
|
|
359
|
+
export function buildSnapshot(runsDir, selection = {}) {
|
|
360
|
+
const { campaigns, corrupt } = discoverCampaigns(runsDir);
|
|
361
|
+
const campaignList = campaigns.map(({ campaign }) => ({ id: campaign.id, goal: oneLine(campaign.goal), status: campaign.status }));
|
|
362
|
+
const entry = resolveCampaign(campaigns, selection.campaignId);
|
|
363
|
+
const selectedId = entry?.campaign.id ?? null;
|
|
364
|
+
const linkedRunIds = entry ? entry.campaign.linkedRunIds : [];
|
|
365
|
+
const runs = linkedRunIds.map((runId) => runSummary(runsDir, runId))
|
|
366
|
+
.sort((left, right) => String(/** @type {any} */ (right).updatedAt ?? "").localeCompare(String(/** @type {any} */ (left).updatedAt ?? "")));
|
|
367
|
+
const payload = {
|
|
368
|
+
schemaVersion: 1,
|
|
369
|
+
generatedAt: new Date().toISOString(),
|
|
370
|
+
campaigns: campaignList,
|
|
371
|
+
corrupt: corrupt.map((entry_) => entry_.id),
|
|
372
|
+
selectedCampaignId: selectedId,
|
|
373
|
+
now: entry ? nowStrip(runsDir, runs) : { state: "idle", runId: null, updatedAt: null },
|
|
374
|
+
needsYou: entry ? needsYouItems(runsDir, linkedRunIds) : [],
|
|
375
|
+
runs,
|
|
376
|
+
drawer: (entry && selection.runId && linkedRunIds.includes(selection.runId)) ? runDrawer(runsDir, selection.runId, selection.nodeId ?? null) : null,
|
|
377
|
+
handoff: entry ? readHandoff(campaignDir(runsDir, entry.campaign.id)) : null,
|
|
378
|
+
};
|
|
379
|
+
return boundSnapshot(payload);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Cheap fingerprint of every file a snapshot for this selection depends on, so the stream can skip re-sending an unchanged snapshot. @param {string} runsDir @param {Selection} selection @returns {string} */
|
|
383
|
+
export function snapshotSignature(runsDir, selection) {
|
|
384
|
+
const { campaigns } = discoverCampaigns(runsDir);
|
|
385
|
+
const parts = [fileSignature([campaignsDir(runsDir)])];
|
|
386
|
+
for (const { path } of campaigns) parts.push(fileSignature([join(path, "campaign.json")]));
|
|
387
|
+
const entry = resolveCampaign(campaigns, selection.campaignId);
|
|
388
|
+
if (!entry) return parts.join("\n");
|
|
389
|
+
parts.push(fileSignature([join(entry.path, "HANDOFF.md")]));
|
|
390
|
+
for (const runId of entry.campaign.linkedRunIds) {
|
|
391
|
+
const runDir = join(runsDir, runId);
|
|
392
|
+
const nodeFiles = listNodeSnapshots(runDir).sort().map((name) => nodeSnapshotPath(runDir, name.slice(0, -".json".length)));
|
|
393
|
+
parts.push(fileSignature([join(runDir, "status.json"), join(runDir, "run.json"), join(runDir, "notify.jsonl"), join(runDir, "events.jsonl"), ...nodeFiles]));
|
|
394
|
+
}
|
|
395
|
+
if (selection.runId && selection.nodeId && entry.campaign.linkedRunIds.includes(selection.runId)) {
|
|
396
|
+
const runDir = join(runsDir, selection.runId);
|
|
397
|
+
const node = /** @type {Record<string, any>|null} */ (readJsonTolerant(nodeSnapshotPath(runDir, selection.nodeId)));
|
|
398
|
+
const worker = node ? lastWorkerInvocation(node.invocations) : null;
|
|
399
|
+
parts.push(fileSignature(/** @type {string[]} */ ([worker?.stdoutPath, worker?.promptPath].filter((value) => typeof value === "string"))));
|
|
400
|
+
}
|
|
401
|
+
return parts.join("\n");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** @param {URL} url @param {string} key @returns {string|null} */
|
|
405
|
+
function safeParam(url, key) {
|
|
406
|
+
const value = url.searchParams.get(key);
|
|
407
|
+
return value && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u.test(value) ? value : null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** @param {URL} url @returns {{campaignId: string|null, runId: string|null, nodeId: string|null}} */
|
|
411
|
+
function selectionOf(url) {
|
|
412
|
+
return { campaignId: safeParam(url, "campaign"), runId: safeParam(url, "run"), nodeId: safeParam(url, "node") };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Read-only dashboard server behind its network boundary: the static page, a snapshot endpoint for polling clients, an SSE stream, and the operator's `/api/*` routes — whose writes all shell out to the runner CLI. Every request carries the bearer token in the Authorization header; a token that arrives in the query string is refused unread. The boundary is settled before `listen` — a public or wildcard bind throws and no socket ever opens. @param {{runsDir: string, tokenFile: string, port?: number, host?: string, pollMs?: number, cliEntry?: string}} options @returns {Promise<import("node:http").Server>} */
|
|
416
|
+
export async function startServer({ runsDir, tokenFile, port = 4173, host = "127.0.0.1", pollMs = STREAM_POLL_MS, cliEntry }) {
|
|
417
|
+
const address = assertPrivateBind(await resolveBindAddress(host));
|
|
418
|
+
const token = loadBearerToken(tokenFile);
|
|
419
|
+
const server = http.createServer((request, response) => {
|
|
420
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
421
|
+
try {
|
|
422
|
+
const refusal = admissionFailure(request, url, token);
|
|
423
|
+
if (refusal) return sendRefusal(response, refusal);
|
|
424
|
+
if (url.pathname === "/" || url.pathname === "/index.html") return sendFile(response, join(HERE, "index.html"), "text/html; charset=utf-8");
|
|
425
|
+
if (url.pathname === "/api/snapshot") return sendJson(response, buildSnapshot(runsDir, selectionOf(url)));
|
|
426
|
+
if (url.pathname === "/api/stream") return streamSnapshots(request, response, runsDir, selectionOf(url), pollMs);
|
|
427
|
+
if (url.pathname.startsWith("/api/")) {
|
|
428
|
+
// A phone client that hangs up mid-request must not take the daemon
|
|
429
|
+
// down as an unhandled rejection; the router catches its own errors,
|
|
430
|
+
// this guards the one write that follows a closed socket.
|
|
431
|
+
handleApiRequest(request, response, { runsDir, cliEntry }).catch(() => {
|
|
432
|
+
if (!response.destroyed) response.destroy();
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
437
|
+
response.end("not found");
|
|
438
|
+
} catch (error) {
|
|
439
|
+
response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
440
|
+
response.end(error instanceof Error ? error.message : String(error));
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
/** The `Promise<void>` type is the JSDoc hint that lets `resolve()` take no argument under checkJs. @type {Promise<void>} */
|
|
444
|
+
const listening = new Promise((resolve, reject) => {
|
|
445
|
+
server.once("error", reject);
|
|
446
|
+
server.listen(port, address, () => {
|
|
447
|
+
server.removeListener("error", reject);
|
|
448
|
+
resolve();
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
await listening;
|
|
452
|
+
return server;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The one admission rule: the token rides the Authorization header and nowhere else. The query string is inspected for its shape — a `token` key, or any parameter equal to the token — and is never echoed back; no refusal or later message repeats what was sent. @param {import("node:http").IncomingMessage} request @param {URL} url @param {string} token @returns {{status: number, reason: string}|null} why the request is refused, or null when it passes */
|
|
456
|
+
function admissionFailure(request, url, token) {
|
|
457
|
+
for (const [key, value] of url.searchParams) {
|
|
458
|
+
if (key.toLowerCase() === "token" || secretEquals(value, token)) return { status: 400, reason: "refused: a token in the query string leaks into proxy logs and shell history; send it in the Authorization header" };
|
|
459
|
+
}
|
|
460
|
+
return secretEquals(request.headers.authorization, `Bearer ${token}`)
|
|
461
|
+
? null
|
|
462
|
+
: { status: 401, reason: "unauthorized: send the dashboard bearer token in the Authorization header" };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Compare a presented credential against the expected one without letting the
|
|
467
|
+
* comparison's duration say how much of it was right. `===` on strings
|
|
468
|
+
* short-circuits at the first differing byte, which is a byte-at-a-time oracle
|
|
469
|
+
* for anyone who can reach the listener and time it. Lengths are compared
|
|
470
|
+
* first because `timingSafeEqual` throws on a length mismatch; the length of
|
|
471
|
+
* the token is not the secret, its bytes are.
|
|
472
|
+
*
|
|
473
|
+
* @param {unknown} presented
|
|
474
|
+
* @param {string} expected
|
|
475
|
+
* @returns {boolean}
|
|
476
|
+
*/
|
|
477
|
+
function secretEquals(presented, expected) {
|
|
478
|
+
if (typeof presented !== "string") return false;
|
|
479
|
+
const left = Buffer.from(presented, "utf8");
|
|
480
|
+
const right = Buffer.from(expected, "utf8");
|
|
481
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** @param {import("node:http").ServerResponse} response @param {{status: number, reason: string}} refusal */
|
|
485
|
+
function sendRefusal(response, refusal) {
|
|
486
|
+
response.writeHead(refusal.status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
487
|
+
response.end(refusal.reason);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Pushes a fresh snapshot on connect and whenever the selection's signature changes. @param {import("node:http").IncomingMessage} request @param {import("node:http").ServerResponse} response @param {string} runsDir @param {Selection} selection @param {number} pollMs */
|
|
491
|
+
function streamSnapshots(request, response, runsDir, selection, pollMs) {
|
|
492
|
+
response.writeHead(200, {
|
|
493
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
494
|
+
"cache-control": "no-store",
|
|
495
|
+
connection: "keep-alive",
|
|
496
|
+
"x-accel-buffering": "no",
|
|
497
|
+
});
|
|
498
|
+
let lastSignature = "";
|
|
499
|
+
const push = () => {
|
|
500
|
+
const signature = snapshotSignature(runsDir, selection);
|
|
501
|
+
if (signature === lastSignature) return;
|
|
502
|
+
lastSignature = signature;
|
|
503
|
+
response.write(`event: update\ndata: ${JSON.stringify(buildSnapshot(runsDir, selection))}\n\n`);
|
|
504
|
+
};
|
|
505
|
+
push();
|
|
506
|
+
const poll = setInterval(() => {
|
|
507
|
+
try { push(); } catch (error) {
|
|
508
|
+
response.write(`event: error\ndata: ${JSON.stringify({ message: error instanceof Error ? error.message : String(error) })}\n\n`);
|
|
509
|
+
}
|
|
510
|
+
}, pollMs);
|
|
511
|
+
const ping = setInterval(() => response.write(`: ping ${Date.now()}\n\n`), STREAM_PING_MS);
|
|
512
|
+
const stop = () => { clearInterval(poll); clearInterval(ping); };
|
|
513
|
+
request.on("close", stop);
|
|
514
|
+
response.on("close", stop);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** @param {import("node:http").ServerResponse} response @param {string} path @param {string} type */
|
|
518
|
+
function sendFile(response, path, type) {
|
|
519
|
+
response.writeHead(200, { "content-type": type, "cache-control": "no-store" });
|
|
520
|
+
response.end(readFileSync(path));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** @param {import("node:http").ServerResponse} response @param {unknown} payload */
|
|
524
|
+
function sendJson(response, payload) {
|
|
525
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
526
|
+
response.end(JSON.stringify(payload));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function main() {
|
|
530
|
+
const arguments_ = process.argv.slice(2);
|
|
531
|
+
let port = 4173;
|
|
532
|
+
let host = "127.0.0.1";
|
|
533
|
+
let cwd = process.cwd();
|
|
534
|
+
let tokenFile = "";
|
|
535
|
+
for (let index = 0; index < arguments_.length; index += 2) {
|
|
536
|
+
if (arguments_[index] === "--port") port = Number(arguments_[index + 1]);
|
|
537
|
+
if (arguments_[index] === "--host") host = String(arguments_[index + 1] ?? "");
|
|
538
|
+
if (arguments_[index] === "--cwd") cwd = String(arguments_[index + 1] ?? "");
|
|
539
|
+
if (arguments_[index] === "--token-file") tokenFile = String(arguments_[index + 1] ?? "");
|
|
540
|
+
}
|
|
541
|
+
const runsDir = join(cwd, ".runs");
|
|
542
|
+
try {
|
|
543
|
+
const server = await startServer({ runsDir, tokenFile: tokenFile || join(runsDir, "dashboard.token"), port, host });
|
|
544
|
+
const address = /** @type {import("node:net").AddressInfo} */ (server.address());
|
|
545
|
+
process.stdout.write(`faberun dashboard on http://${address.address}:${address.port} (runs: ${runsDir})\n`);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
548
|
+
process.exitCode = 1;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) main();
|