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,352 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
6
|
+
|
|
7
|
+
/** @typedef {{status: "ready", path: string, branch: string, commit: string|null, baseSha: string}} AttemptWorktree */
|
|
8
|
+
/** @typedef {{sha: string, empty: boolean}} SealedAttempt */
|
|
9
|
+
/** @typedef {{encoding?: "utf8"|"buffer", stdio?: import("node:child_process").StdioOptions, timeoutMs?: number, maxBuffer?: number, cwd?: string, env?: NodeJS.ProcessEnv}} BoundedGitOptions */
|
|
10
|
+
/** @typedef {{status: number|null, signal: NodeJS.Signals|null, stdout: string|Buffer, stderr: string|Buffer, error?: Error & {code?: string}, timedOut: boolean}} BoundedGitResult */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The wall-clock bound on every synchronous git subprocess. A controller that
|
|
14
|
+
* blocks forever on `git` while another process holds `.git/index.lock` is a
|
|
15
|
+
* frozen loop; git has no timeout of its own, so the wrapper supplies one.
|
|
16
|
+
* 30s is far longer than any one of these calls takes at the sizes this runner
|
|
17
|
+
* uses, and far shorter than an operator waiting on a silent run.
|
|
18
|
+
*/
|
|
19
|
+
export const GIT_SYNC_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The timeout a call uses: the explicit option, else the operator override
|
|
23
|
+
* (`FABERUN_GIT_TIMEOUT_MS`, for a slow disk or a test), else the
|
|
24
|
+
* default. Read at call time so the env is honoured without a restart.
|
|
25
|
+
*
|
|
26
|
+
* @param {number|undefined} optionMs
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
function gitSyncTimeoutMs(optionMs) {
|
|
30
|
+
if (optionMs !== undefined) return optionMs;
|
|
31
|
+
const raw = process.env.FABERUN_GIT_TIMEOUT_MS;
|
|
32
|
+
const parsed = raw === undefined ? Number.NaN : Number(raw);
|
|
33
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : GIT_SYNC_TIMEOUT_MS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The one place a synchronous `git` process is spawned. `execFileSync`-style
|
|
38
|
+
* callers (`runGit`) read `stdout`, `spawnSync`-style callers read the result
|
|
39
|
+
* object, and both are held to the same timeout. A spawn that hits the timeout
|
|
40
|
+
* carries a named `git_timeout` error instead of hanging.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} args
|
|
43
|
+
* @param {BoundedGitOptions} [options]
|
|
44
|
+
* @returns {BoundedGitResult}
|
|
45
|
+
*/
|
|
46
|
+
export function boundedGitSync(args, options = {}) {
|
|
47
|
+
const timeoutMs = gitSyncTimeoutMs(options.timeoutMs);
|
|
48
|
+
const result = spawnSync("git", args, {
|
|
49
|
+
encoding: options.encoding ?? "utf8",
|
|
50
|
+
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
|
51
|
+
timeout: timeoutMs,
|
|
52
|
+
killSignal: "SIGKILL",
|
|
53
|
+
...(options.maxBuffer !== undefined ? { maxBuffer: options.maxBuffer } : {}),
|
|
54
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
55
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
56
|
+
});
|
|
57
|
+
const timedOut = result.error !== undefined && /** @type {{code?: string}} */ (result.error).code === "ETIMEDOUT";
|
|
58
|
+
if (timedOut) {
|
|
59
|
+
/** @type {Error & {code?: string}} */
|
|
60
|
+
const error = new Error(`git ${args.join(" ")} timed out after ${timeoutMs}ms`);
|
|
61
|
+
error.code = "git_timeout";
|
|
62
|
+
error.cause = result.error;
|
|
63
|
+
return { ...result, error, timedOut: true };
|
|
64
|
+
}
|
|
65
|
+
return { ...result, timedOut: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run git and, when it fails, carry git's own stderr into the error.
|
|
70
|
+
*
|
|
71
|
+
* Node's `execFileSync` error says only `Command failed: git -C … commit -qm
|
|
72
|
+
* …` and drops the reason. That is how an empty-change-set commit exiting 1
|
|
73
|
+
* was misdiagnosed twice across two campaigns: the surfaced error named the
|
|
74
|
+
* command, never git's "nothing to commit". Every git call here goes through
|
|
75
|
+
* this helper so a failure always says why.
|
|
76
|
+
*
|
|
77
|
+
* @param {string[]} args @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function runGit(args) {
|
|
80
|
+
const result = boundedGitSync(args, { encoding: "utf8" });
|
|
81
|
+
if (result.error || result.status !== 0) {
|
|
82
|
+
const error = /** @type {Error & {stderr?: unknown, stdout?: unknown, status?: number|null, signal?: string|null}} */ (result.error ?? new Error(`Command failed: git ${args.join(" ")}`));
|
|
83
|
+
if (result.error === undefined) {
|
|
84
|
+
error.stderr = result.stderr;
|
|
85
|
+
error.stdout = result.stdout;
|
|
86
|
+
error.status = result.status;
|
|
87
|
+
error.signal = result.signal;
|
|
88
|
+
}
|
|
89
|
+
const reason = gitFailureReason(error);
|
|
90
|
+
if (reason && !String(error.message).includes(reason)) error.message = `${String(error.message).split("\n")[0]}: ${reason}`;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
return String(result.stdout ?? "").trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {string} runId @returns {string} */
|
|
97
|
+
export function runRefName(runId) {
|
|
98
|
+
return `refs/faberun/${runId}/run`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {string} runId @returns {string} */
|
|
102
|
+
export function candidateRefName(runId) {
|
|
103
|
+
return `refs/faberun/${runId}/candidate`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {string} runDir @param {string} runId @returns {string} */
|
|
107
|
+
function worktreeRoot(runDir, runId) {
|
|
108
|
+
return join(dirname(runDir), "worktrees", runId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {string} runDir @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
|
|
112
|
+
export function attemptWorktreePath(runDir, runId, nodeId, attempt) {
|
|
113
|
+
return join(worktreeRoot(runDir, runId), `${nodeId}.${attempt}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
|
|
117
|
+
function attemptBranchName(runId, nodeId, attempt) {
|
|
118
|
+
return `faberun/${runId}/${nodeId}/${attempt}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {string} runDir @param {string} runId @returns {string} */
|
|
122
|
+
export function candidateWorktreePath(runDir, runId) {
|
|
123
|
+
return join(worktreeRoot(runDir, runId), ".candidate");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {unknown} error @returns {string} */
|
|
127
|
+
function gitFailureReason(error) {
|
|
128
|
+
const streams = /** @type {{stderr?: unknown, stdout?: unknown}} */ (error ?? {});
|
|
129
|
+
return [streams.stderr, streams.stdout]
|
|
130
|
+
.map((stream) => (typeof stream === "string" ? stream : stream ? String(stream) : ""))
|
|
131
|
+
.map((text) => text.trim())
|
|
132
|
+
.find(Boolean) ?? "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {string} repo @param {string[]} args @returns {string} */
|
|
136
|
+
export function git(repo, args) {
|
|
137
|
+
return runGit(["-C", repo, ...args]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {string} repo @param {string} [ref] @returns {string|null} */
|
|
141
|
+
export function gitHead(repo, ref = "HEAD") {
|
|
142
|
+
try {
|
|
143
|
+
return git(repo, ["rev-parse", ref]);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The path of the worktree that has `branch` checked out right now, or null.
|
|
151
|
+
* The operator's own checkout is included: `git worktree list` reports it, so
|
|
152
|
+
* moving that branch under a live checkout is exactly what this guards.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} repo
|
|
155
|
+
* @param {string} branch
|
|
156
|
+
* @returns {string|null}
|
|
157
|
+
*/
|
|
158
|
+
export function worktreeCheckedOutAt(repo, branch) {
|
|
159
|
+
const ref = `refs/heads/${branch}`;
|
|
160
|
+
let output;
|
|
161
|
+
try {
|
|
162
|
+
output = git(repo, ["worktree", "list", "--porcelain"]);
|
|
163
|
+
} catch {
|
|
164
|
+
// A repository that cannot list its worktrees cannot prove the branch is
|
|
165
|
+
// safe to move; the caller treats null as "not checked out".
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
let path = null;
|
|
169
|
+
for (const line of output.split("\n")) {
|
|
170
|
+
if (line.startsWith("worktree ")) path = line.slice("worktree ".length);
|
|
171
|
+
else if (line.startsWith("branch ") && line.slice("branch ".length) === ref) return path;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @param {string} repo @param {string} runId @param {string|null|undefined} head @returns {string} */
|
|
177
|
+
export function createRunRef(repo, runId, head) {
|
|
178
|
+
if (!head) throw Object.assign(new Error("an execution repository must have at least one commit"), { code: "git_head_required" });
|
|
179
|
+
const ref = runRefName(runId);
|
|
180
|
+
if (gitHead(repo, ref)) throw new Error(`run ref already exists: ${ref}`);
|
|
181
|
+
runGit(["-C", repo, "update-ref", ref, head]);
|
|
182
|
+
return ref;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string}} args
|
|
187
|
+
* `base` cuts the new branch from a sealed sha instead of the run ref tip —
|
|
188
|
+
* the previous attempt's sealed work, when it left one (TECH-SPEC lean
|
|
189
|
+
* v0.3 section 3 rule 4). Omitted, it falls back to the run ref tip as
|
|
190
|
+
* before.
|
|
191
|
+
* @returns {AttemptWorktree}
|
|
192
|
+
*/
|
|
193
|
+
export function createAttemptWorktree({ repo, runDir, runId, nodeId, attempt, base }) {
|
|
194
|
+
const runRefSha = gitHead(repo, runRefName(runId));
|
|
195
|
+
if (!runRefSha) throw Object.assign(new Error(`integration ref is unavailable for ${runId}`), { code: "run_ref_missing" });
|
|
196
|
+
const path = attemptWorktreePath(runDir, runId, nodeId, attempt);
|
|
197
|
+
const branch = attemptBranchName(runId, nodeId, attempt);
|
|
198
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
199
|
+
const existingCommit = gitHead(path);
|
|
200
|
+
const existingBranch = gitHead(repo, branch);
|
|
201
|
+
if (existingCommit) {
|
|
202
|
+
if (existingBranch !== existingCommit) throw new Error(`attempt worktree identity does not match ${branch}: ${path}`);
|
|
203
|
+
} else if (existingBranch) {
|
|
204
|
+
runGit(["-C", repo, "worktree", "add", path, branch]);
|
|
205
|
+
} else {
|
|
206
|
+
runGit(["-C", repo, "worktree", "add", path, "-b", branch, base ?? runRefName(runId)]);
|
|
207
|
+
}
|
|
208
|
+
prepareWorktreeEnvironment(repo, path);
|
|
209
|
+
return { status: "ready", path, branch, commit: gitHead(path), baseSha: base ?? runRefSha };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Give a fresh worktree the environment repository tooling needs: the
|
|
214
|
+
* installed `node_modules`, linked as a symlink and never copied, so
|
|
215
|
+
* commitlint through the commit-msg hook and `npm run typecheck` work without
|
|
216
|
+
* an install. A no-op when the repository has nothing installed, or the
|
|
217
|
+
* worktree already has an entry at that path.
|
|
218
|
+
*
|
|
219
|
+
* Every worktree a run creates goes through here — attempts and the
|
|
220
|
+
* integration candidate alike. That is the point of the single function: the
|
|
221
|
+
* candidate re-runs the verification the attempt just passed, so any
|
|
222
|
+
* environment the attempt had and the candidate lacked turns a correct node
|
|
223
|
+
* into a failed one, and the failure names the node rather than the missing
|
|
224
|
+
* install.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} repo @param {string} path @returns {void}
|
|
227
|
+
*/
|
|
228
|
+
function prepareWorktreeEnvironment(repo, path) {
|
|
229
|
+
const source = join(repo, "node_modules");
|
|
230
|
+
if (!existsSync(source)) return;
|
|
231
|
+
const target = join(path, "node_modules");
|
|
232
|
+
if (existsSync(target) || isSymlink(target)) return;
|
|
233
|
+
symlinkSync(source, target);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @param {string} path @returns {boolean} */
|
|
237
|
+
function isSymlink(path) {
|
|
238
|
+
try {
|
|
239
|
+
return lstatSync(path).isSymbolicLink();
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {{repo: string, path: string, baseSha: string|null, runId: string, nodeId: string, attempt: number}} args
|
|
247
|
+
* @returns {SealedAttempt}
|
|
248
|
+
*/
|
|
249
|
+
export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
|
|
250
|
+
// The attempt-local `.runs` result sidecar must never enter the attempt
|
|
251
|
+
// commit. Naming it through an exclude pathspec makes `git add` exit 1 with
|
|
252
|
+
// advice.addIgnoredFile as soon as the sidecar exists in a repository that
|
|
253
|
+
// ignores `.runs/` (every real worker writes it), so stage everything and
|
|
254
|
+
// unstage the sidecar afterwards; that also covers a repository that does
|
|
255
|
+
// not ignore it.
|
|
256
|
+
//
|
|
257
|
+
// The probe below must therefore exclude exactly what the staging step
|
|
258
|
+
// unstages, `node_modules` included: `node_modules/` in .gitignore does not
|
|
259
|
+
// match the symlink of the same name, so a re-sealed attempt whose only
|
|
260
|
+
// entry is that link would look dirty, stage it, unstage it, and commit an
|
|
261
|
+
// empty change set — which exits 1 and turns every retry of an
|
|
262
|
+
// already-sealed attempt into a hard failure.
|
|
263
|
+
const dirty = git(path, ["status", "--porcelain=v1", "--", ".", ":(exclude).runs", ":(exclude)node_modules"]);
|
|
264
|
+
if (dirty) {
|
|
265
|
+
runGit(["-C", path, "add", "-A", "--", "."]);
|
|
266
|
+
// node_modules is linked into the worktree as a symlink, which `node_modules/`
|
|
267
|
+
// in .gitignore does not match; never let the link into the attempt commit.
|
|
268
|
+
runGit(["-C", path, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".runs", "node_modules"]);
|
|
269
|
+
runGit([
|
|
270
|
+
"-C", path,
|
|
271
|
+
"-c", "user.email=runner@example.test",
|
|
272
|
+
"-c", "user.name=faberun",
|
|
273
|
+
"-c", "commit.gpgSign=false",
|
|
274
|
+
// The seal is bookkeeping, not a contribution: it checkpoints one
|
|
275
|
+
// attempt's worktree onto a throwaway `faberun/<run>/<node>/<attempt>` branch
|
|
276
|
+
// so the next attempt can build on it, and nothing here is ever pushed.
|
|
277
|
+
// Running the target repository's hooks on it is wrong twice over.
|
|
278
|
+
// Measured 2026-09-13 against a repository with a plain failing
|
|
279
|
+
// `.git/hooks/pre-commit`: every seal failed, and because the commit
|
|
280
|
+
// message never varies between attempts, every node of every run failed
|
|
281
|
+
// the same way with no way out. A lint or test hook is also work the
|
|
282
|
+
// controller already does deliberately through `verification`, on a
|
|
283
|
+
// schedule it chose. The identity and signing overrides above are the
|
|
284
|
+
// same argument: this commit answers to the factory, not to the repo's
|
|
285
|
+
// conventions for human commits.
|
|
286
|
+
"commit", "--no-verify", "-qm", `faberun ${runId} ${nodeId} attempt ${attempt}`,
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
const sha = gitHead(path);
|
|
290
|
+
if (!sha) throw new Error(`attempt worktree has no commit: ${path}`);
|
|
291
|
+
const empty = Boolean(baseSha && gitDiffEmpty(path, baseSha, sha));
|
|
292
|
+
return { sha, empty };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @param {string} repo @param {string} base @param {string} head @returns {boolean} */
|
|
296
|
+
export function gitDiffEmpty(repo, base, head) {
|
|
297
|
+
try {
|
|
298
|
+
runGit(["-C", repo, "diff", "--quiet", base, head]);
|
|
299
|
+
return true;
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** @param {string} repo @param {string} ref @param {string} next @param {string} previous @returns {void} */
|
|
306
|
+
export function updateRefConditional(repo, ref, next, previous) {
|
|
307
|
+
runGit(["-C", repo, "update-ref", ref, next, previous]);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** @param {string} repo @param {string} ref @returns {void} */
|
|
311
|
+
export function deleteRef(repo, ref) {
|
|
312
|
+
try {
|
|
313
|
+
runGit(["-C", repo, "update-ref", "-d", ref]);
|
|
314
|
+
} catch {
|
|
315
|
+
// Deleting an already absent cleanup ref is idempotent.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {{repo: string, runDir: string, runId: string, ref?: string}} args
|
|
321
|
+
* @returns {string}
|
|
322
|
+
*/
|
|
323
|
+
export function createCandidateWorktree({ repo, runDir, runId, ref = candidateRefName(runId) }) {
|
|
324
|
+
const path = candidateWorktreePath(runDir, runId);
|
|
325
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
326
|
+
runGit(["-C", repo, "worktree", "add", "--detach", path, ref]);
|
|
327
|
+
prepareWorktreeEnvironment(repo, path);
|
|
328
|
+
return path;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** @param {string} repo @param {string|null|undefined} path @returns {void} */
|
|
332
|
+
export function removeWorktree(repo, path) {
|
|
333
|
+
if (!path) return;
|
|
334
|
+
try {
|
|
335
|
+
runGit(["-C", repo, "worktree", "remove", "--force", path]);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (existsSync(path)) rmSync(path, { recursive: true, force: true });
|
|
338
|
+
else if (/** @type {{status?: number}} */ (error)?.status !== 128) throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** @param {string} repo @param {string} runDir @param {string} runId @returns {void} */
|
|
343
|
+
export function cleanupCandidate(repo, runDir, runId) {
|
|
344
|
+
removeWorktree(repo, candidateWorktreePath(runDir, runId));
|
|
345
|
+
deleteRef(repo, candidateRefName(runId));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** @param {NodeSnapshot|undefined} state @returns {string|null} */
|
|
349
|
+
export function attemptWorkspace(state) {
|
|
350
|
+
const path = state?.worktree?.path;
|
|
351
|
+
return path && state?.worktree?.status !== "removed" && existsSync(path) ? path : null;
|
|
352
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run's closing artifacts: the final status table, the report, and
|
|
3
|
+
* `findings.json`. Written once when the controller settles, from the persisted
|
|
4
|
+
* node snapshots alone.
|
|
5
|
+
*
|
|
6
|
+
* This is presentation. It lived inside the engine only because that is where
|
|
7
|
+
* the loop happened to end; nothing in the control path reads what it writes.
|
|
8
|
+
*/
|
|
9
|
+
import { compactCost, compactTokens, errorCode } from "../util.mjs";
|
|
10
|
+
import { basename, join } from "node:path";
|
|
11
|
+
import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
12
|
+
import { scopeFindingsNote } from "../contract/scope-findings.mjs";
|
|
13
|
+
import { MARK, fit, roleCosts, statusNote, writeStatusArtifacts } from "./render.mjs";
|
|
14
|
+
import { unlinkSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
/** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
|
|
17
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
18
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} runDir
|
|
22
|
+
* @param {string} runsDir
|
|
23
|
+
* @param {ValidatedContract} contract
|
|
24
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
25
|
+
* @param {LockHandle|null} [lock]
|
|
26
|
+
*/
|
|
27
|
+
export function render(runDir, runsDir, contract, states, lock = null) {
|
|
28
|
+
lock?.assert();
|
|
29
|
+
writeTextAtomic(join(runDir, "STATUS.md"), renderFinalStatus(runDir, contract, states));
|
|
30
|
+
writeStatusArtifacts(runDir, runsDir, contract, states);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} runDir
|
|
34
|
+
* @param {ValidatedContract} contract
|
|
35
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function renderFinalStatus(runDir, contract, states) {
|
|
39
|
+
const nodes = /** @type {NodeSnapshot[]} */ (contract.nodes.map((node) => states.get(node.id)).filter((node) => node !== undefined));
|
|
40
|
+
const runMetadata = /** @type {{identityWarnings?: string[]}} */ (readJson(join(runDir, "run.json")) ?? {});
|
|
41
|
+
const identityWarnings = runMetadata.identityWarnings ?? [];
|
|
42
|
+
const counts = new Map();
|
|
43
|
+
for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
|
|
44
|
+
const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(" · ");
|
|
45
|
+
// The note carries every advisory marker a node earned (scope finding,
|
|
46
|
+
// review verdict, gate summary), so the cell holds the composed note whole.
|
|
47
|
+
const widths = [3, 24, 9, 28, 7, 64];
|
|
48
|
+
/** @param {unknown[]} cells */
|
|
49
|
+
const row = (cells) => cells.map((cell, index) => fit(String(cell ?? ""), widths[index])).join(" ");
|
|
50
|
+
const lines = [
|
|
51
|
+
`# run ${basename(runDir)}`,
|
|
52
|
+
"",
|
|
53
|
+
contract.goal,
|
|
54
|
+
"",
|
|
55
|
+
`${nodes.length} nodes · ${summary}`,
|
|
56
|
+
"",
|
|
57
|
+
"```",
|
|
58
|
+
row(["", "NODE", "STATE", "RUNTIME", "TRY", "NOTE"]),
|
|
59
|
+
row(widths.map((width) => "-".repeat(width))),
|
|
60
|
+
];
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
const runtime = node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : "-";
|
|
63
|
+
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
64
|
+
const detail = statusNote(node) ?? "-";
|
|
65
|
+
// A scope finding leads the note and drops the phase boilerplate: the
|
|
66
|
+
// operator has to see it, and the fixed cell cannot hold both.
|
|
67
|
+
const note = scopeFindingsNote(node.scopeFindings)
|
|
68
|
+
? detail
|
|
69
|
+
: `${detail} · phase ${planNode?.phase ?? "-"} · ${node.invocations?.at(-1)?.continuationMode ?? "fresh"}`;
|
|
70
|
+
lines.push(row([MARK[node.status] ?? "[?]", node.id, node.status, runtime, node.attempt ?? 0, note]));
|
|
71
|
+
}
|
|
72
|
+
lines.push("```", "", "## Needs you", "");
|
|
73
|
+
const attention = nodes.filter((node) => !["pending", "running", "done"].includes(node.status));
|
|
74
|
+
if (!attention.length && !identityWarnings.length) lines.push("Nothing needs you right now.");
|
|
75
|
+
for (const warning of identityWarnings) lines.push(`- [~] ${warning}`);
|
|
76
|
+
for (const node of attention) lines.push(`- ${MARK[node.status] ?? "[?]"} ${node.id}: ${node.gate?.summary ?? node.error?.message ?? node.status}`);
|
|
77
|
+
return `${lines.join("\n")}\n`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} runDir
|
|
81
|
+
* @param {ValidatedContract} contract
|
|
82
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function renderFinalReport(runDir, contract, states) {
|
|
86
|
+
const nodes = /** @type {NodeSnapshot[]} */ (contract.nodes.map((node) => states.get(node.id)).filter((node) => node !== undefined));
|
|
87
|
+
const counts = new Map();
|
|
88
|
+
for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
|
|
89
|
+
const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(" · ");
|
|
90
|
+
const widths = [3, 24, 9, 7, 7, 28, 10, 10, 10, 12, 64];
|
|
91
|
+
/** @param {unknown[]} cells */
|
|
92
|
+
const row = (cells) => cells.map((cell, index) => fit(String(cell ?? ""), widths[index])).join(" ");
|
|
93
|
+
const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 };
|
|
94
|
+
let totalCostUsd = null;
|
|
95
|
+
const lines = [
|
|
96
|
+
`# run ${basename(runDir)}`,
|
|
97
|
+
"",
|
|
98
|
+
`${nodes.length} nodes · ${summary}`,
|
|
99
|
+
"",
|
|
100
|
+
"```",
|
|
101
|
+
row(["", "NODE", "STATE", "TRY", "REV", "RUNTIME", "IN", "OUT", "CACHE", "COST", "NOTE"]),
|
|
102
|
+
row(widths.map((width) => "-".repeat(width))),
|
|
103
|
+
];
|
|
104
|
+
for (const node of nodes) {
|
|
105
|
+
const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
|
|
106
|
+
totals.inputTokens += usage.inputTokens ?? 0;
|
|
107
|
+
totals.outputTokens += usage.outputTokens ?? 0;
|
|
108
|
+
totals.cacheReadInputTokens += usage.cacheReadInputTokens ?? 0;
|
|
109
|
+
if (typeof node.costUsd === "number" && Number.isFinite(node.costUsd)) totalCostUsd = (totalCostUsd ?? 0) + node.costUsd;
|
|
110
|
+
const runtime = node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : "-";
|
|
111
|
+
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
112
|
+
const detail = node.gate?.summary ?? node.error?.message ?? (node.blockedBy?.length ? node.blockedBy.join(", ") : null) ?? (typeof node.result === "string" && node.result.trim() ? node.result.trim() : node.phase ?? "-");
|
|
113
|
+
// The advisory scope finding leads the note, as it does in STATUS.md.
|
|
114
|
+
const note = scopeFindingsNote(node.scopeFindings)
|
|
115
|
+
? `${scopeFindingsNote(node.scopeFindings)} · ${detail}`
|
|
116
|
+
: `${detail} · phase ${planNode?.phase ?? "-"} · ${node.invocations?.at(-1)?.continuationMode ?? "fresh"}`;
|
|
117
|
+
lines.push(row([
|
|
118
|
+
MARK[node.status] ?? "[?]",
|
|
119
|
+
node.id,
|
|
120
|
+
node.status,
|
|
121
|
+
node.attempt ?? 0,
|
|
122
|
+
node.revisions ?? 0,
|
|
123
|
+
runtime,
|
|
124
|
+
compactTokens(usage.inputTokens),
|
|
125
|
+
compactTokens(usage.outputTokens),
|
|
126
|
+
compactTokens(usage.cacheReadInputTokens),
|
|
127
|
+
compactCost(node.costUsd),
|
|
128
|
+
note,
|
|
129
|
+
]));
|
|
130
|
+
}
|
|
131
|
+
const roles = roleCosts(nodes);
|
|
132
|
+
lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)}`);
|
|
133
|
+
return `${lines.join("\n")}\n`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Consolidated terminal-state handoff: one bounded JSON snapshot in the run
|
|
137
|
+
* dir so a triage session never loads full run state. Nodes stay the source
|
|
138
|
+
* of truth; this file is a snapshot of the moment the run finished. Written
|
|
139
|
+
* when any node ended non-done; removed when a later resume drives the run
|
|
140
|
+
* fully done, so a stale snapshot cannot outlive the state it described.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} runDir
|
|
143
|
+
* @param {ValidatedContract} contract
|
|
144
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
145
|
+
*/
|
|
146
|
+
export function writeFindingsArtifact(runDir, contract, states) {
|
|
147
|
+
const failing = [...states.values()].filter((state) => state.status !== "done");
|
|
148
|
+
const path = join(runDir, "findings.json");
|
|
149
|
+
if (!failing.length) {
|
|
150
|
+
try { unlinkSync(path); } catch (error) {
|
|
151
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const counts = new Map();
|
|
156
|
+
for (const state of states.values()) counts.set(state.status, (counts.get(state.status) ?? 0) + 1);
|
|
157
|
+
writeJsonAtomic(path, {
|
|
158
|
+
schemaVersion: 1,
|
|
159
|
+
run: contract.id,
|
|
160
|
+
goal: contract.goal,
|
|
161
|
+
summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
|
|
162
|
+
nodes: failing.map((state) => ({
|
|
163
|
+
id: state.id,
|
|
164
|
+
status: state.status,
|
|
165
|
+
attempt: state.attempt,
|
|
166
|
+
revisions: state.revisions,
|
|
167
|
+
error: state.error,
|
|
168
|
+
gate: state.gate,
|
|
169
|
+
...(state.blockedBy?.length ? { blockedBy: state.blockedBy } : {}),
|
|
170
|
+
...missingContextOf(state),
|
|
171
|
+
...unexpectedPathsOf(state),
|
|
172
|
+
})),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* @param {NodeSnapshot} state
|
|
177
|
+
* @returns {{missingContext?: string[]}}
|
|
178
|
+
*/
|
|
179
|
+
function missingContextOf(state) {
|
|
180
|
+
const result = /** @type {{missingContext?: unknown}|null} */ (state.result);
|
|
181
|
+
if (result && Array.isArray(result.missingContext) && result.missingContext.length) {
|
|
182
|
+
return { missingContext: result.missingContext.map(String) };
|
|
183
|
+
}
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* An `unexpected_write` failure is only actionable with the offending paths,
|
|
188
|
+
* and the bounded error message truncates them. Carry a bounded list into the
|
|
189
|
+
* artifact so triage never has to open the node file.
|
|
190
|
+
*
|
|
191
|
+
* @param {NodeSnapshot} state
|
|
192
|
+
* @returns {{unexpectedPaths?: string[]}}
|
|
193
|
+
*/
|
|
194
|
+
function unexpectedPathsOf(state) {
|
|
195
|
+
const scope = /** @type {{unexpectedPaths?: unknown}|null|undefined} */ (state.scope);
|
|
196
|
+
if (scope && Array.isArray(scope.unexpectedPaths) && scope.unexpectedPaths.length) {
|
|
197
|
+
return { unexpectedPaths: scope.unexpectedPaths.slice(0, 16).map(String) };
|
|
198
|
+
}
|
|
199
|
+
return {};
|
|
200
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation of a metrics projection (TECH-SPEC lean section 6).
|
|
3
|
+
*
|
|
4
|
+
* `metrics.mjs` measures a campaign; this module is the only place that
|
|
5
|
+
* decides how a measurement is printed. Both forms render the whole indicator
|
|
6
|
+
* set and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* The report is bounded by construction — one header line plus one line per
|
|
9
|
+
* indicator, with grouped values elided past `MAX_GROUPS` — so a campaign with
|
|
10
|
+
* many lanes or runtimes prints in the same space as one with few. The
|
|
11
|
+
* dependency runs one way: the projector imports the renderers, and nothing
|
|
12
|
+
* here reads a filesystem path or a projection's inputs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @typedef {import("../campaign/metrics.mjs").CampaignMetrics} CampaignMetrics */
|
|
16
|
+
/** @typedef {import("../campaign/metrics.mjs").MetricsSources} MetricsSources */
|
|
17
|
+
|
|
18
|
+
/** Schema of the `--json` form; bumped when a consumer would have to change. */
|
|
19
|
+
const METRICS_SCHEMA_VERSION = 2;
|
|
20
|
+
/** Indicators whose value is a duration in seconds; the rest are ratios, counts or tokens. */
|
|
21
|
+
const SECONDS_INDICATORS = new Set(["wallClockSec"]);
|
|
22
|
+
/** Groups printed per grouped indicator before the line is elided; the report stays bounded. */
|
|
23
|
+
const MAX_GROUPS = 6;
|
|
24
|
+
/** The runtime-by-kind breakdown doubles the key length of every other grouped indicator, so it elides sooner. */
|
|
25
|
+
/** @type {Record<string, number>} */
|
|
26
|
+
const MAX_GROUPS_BY_INDICATOR = { usageTokensByKindByRuntime: 2 };
|
|
27
|
+
/** Large token counts compact to a short suffix in the two token indicators; every other grouped value prints as-is. */
|
|
28
|
+
const COMPACT_TOKEN_INDICATORS = new Set(["usageTokensByKind", "usageTokensByKindByRuntime"]);
|
|
29
|
+
const NAME_WIDTH = 28;
|
|
30
|
+
const SECONDS_PER_HOUR = 3600;
|
|
31
|
+
/** Hours print at the four decimals the projector rounds its own values to. */
|
|
32
|
+
const HOURS_PRECISION = 10_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The bounded human report: one line per indicator, always the full set.
|
|
36
|
+
*
|
|
37
|
+
* @param {MetricsSources} sources
|
|
38
|
+
* @param {CampaignMetrics} metrics
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function renderMetricsReport(sources, metrics) {
|
|
42
|
+
const lines = [
|
|
43
|
+
`[metrics] ${sources.campaignId} · ${sources.runIds.length} runs · ${sources.events.length} events · ${Object.keys(metrics).length} indicators`,
|
|
44
|
+
];
|
|
45
|
+
for (const [name, indicator] of Object.entries(metrics)) {
|
|
46
|
+
const records = indicator.count === 1 ? "1 record" : `${indicator.count} records`;
|
|
47
|
+
const unknown = typeof (/** @type {{unknownCount?: number}} */ (indicator).unknownCount) === "number"
|
|
48
|
+
? ` · ${(/** @type {{unknownCount: number}} */ (indicator)).unknownCount} unknown`
|
|
49
|
+
: "";
|
|
50
|
+
lines.push(`${name.padEnd(NAME_WIDTH)} ${indicator.direction.padEnd(11)} ${formatValue(name, indicator.value).padEnd(24)} · ${records}${unknown}`);
|
|
51
|
+
}
|
|
52
|
+
return `${lines.join("\n")}\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The machine-readable form, one JSON line like `preflight --json`.
|
|
57
|
+
*
|
|
58
|
+
* @param {MetricsSources} sources
|
|
59
|
+
* @param {CampaignMetrics} metrics
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function renderMetricsJson(sources, metrics) {
|
|
63
|
+
return `${JSON.stringify({
|
|
64
|
+
schemaVersion: METRICS_SCHEMA_VERSION,
|
|
65
|
+
campaignId: sources.campaignId,
|
|
66
|
+
runs: sources.runIds.length,
|
|
67
|
+
events: sources.events.length,
|
|
68
|
+
indicators: metrics,
|
|
69
|
+
})}\n`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} name
|
|
74
|
+
* @param {number|Record<string, number>|null} value
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function formatValue(name, value) {
|
|
78
|
+
if (value === null) return "no record";
|
|
79
|
+
if (typeof value === "number") return SECONDS_INDICATORS.has(name) ? formatSeconds(value) : String(value);
|
|
80
|
+
const maxGroups = MAX_GROUPS_BY_INDICATOR[name] ?? MAX_GROUPS;
|
|
81
|
+
const compact = COMPACT_TOKEN_INDICATORS.has(name);
|
|
82
|
+
const groups = Object.entries(value);
|
|
83
|
+
const shown = groups.slice(0, maxGroups).map(([group, measurement]) => `${group}=${compact ? compactCount(measurement) : measurement}`);
|
|
84
|
+
if (groups.length > shown.length) shown.push(`+${groups.length - shown.length} more`);
|
|
85
|
+
return shown.join(" ");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {number} value @returns {string} */
|
|
89
|
+
function compactCount(value) {
|
|
90
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
91
|
+
if (value >= 1_000) return `${Math.round(value / 1_000)}k`;
|
|
92
|
+
return String(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {number} seconds @returns {string} */
|
|
96
|
+
function formatSeconds(seconds) {
|
|
97
|
+
const hours = seconds / SECONDS_PER_HOUR;
|
|
98
|
+
return hours >= 1 ? `${seconds}s (${Math.round(hours * HOURS_PRECISION) / HOURS_PRECISION}h)` : `${seconds}s`;
|
|
99
|
+
}
|