@haiyangbg/buildbeat 1.21.0 → 2.0.0-beta.2
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/CHANGELOG.md +21 -0
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/bin/buildbeat-v2.js +6 -0
- package/docs/BuildBeat v2/357/274/232AI /345/216/237/347/224/237/350/275/257/344/273/266/344/272/244/344/273/230/346/216/247/345/210/266/345/271/263/351/235/242.md" +2053 -0
- package/docs/CAPABILITY-MATRIX.md +4 -4
- package/docs/CLI.md +4 -4
- package/docs/EXECUTION-PLAN.md +1 -1
- package/docs/RELEASING.md +5 -5
- package/docs/ROADMAP.md +4 -1
- package/docs/V1.21-RELEASE-EVIDENCE-2026-08-25.md +55 -0
- package/docs/V2-D2-DECISION-CARD.md +37 -0
- package/docs/V2-DECISIONS.md +11 -0
- package/docs/V2-ITERATION-01.md +60 -0
- package/docs/V2-ITERATION-02.md +32 -0
- package/docs/V2-ITERATION-03.md +30 -0
- package/docs/V2-ITERATION-04.md +29 -0
- package/docs/V2-ITERATION-05.md +20 -0
- package/docs/V2-ITERATION-06.md +18 -0
- package/docs/V2-ITERATION-07.md +36 -0
- package/docs/V2-PLAN.md +333 -0
- package/docs/V2-PROPOSAL.md +319 -0
- package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +41 -0
- package/docs/v2/M1-ACCEPTANCE-2026-08-28.md +38 -0
- package/docs/v2/M2-DOD-2026-08-28.md +34 -0
- package/docs/v2/M4-CHICKAI-PILOT-2026-08-28.md +44 -0
- package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +46 -0
- package/docs/v2/M4-SELFHOST-2026-08-28.md +53 -0
- package/docs/v2/RFC-0001-product-definition.md +92 -0
- package/docs/v2/RFC-0002-domain-model.md +149 -0
- package/docs/v2/RFC-0003-workflow-policy.md +204 -0
- package/docs/v2/SPEC-0001-events-v1.md +98 -0
- package/docs/v2/guide/01-quickstart.md +92 -0
- package/docs/v2/guide/02-workflow-guide.md +42 -0
- package/docs/v2/guide/03-policy-guide.md +53 -0
- package/docs/v2/guide/04-adapter-guide.md +45 -0
- package/docs/v2/guide/05-worker-contract.md +37 -0
- package/docs/v2/guide/06-evidence-guide.md +38 -0
- package/docs/v2/guide/07-approval-guide.md +34 -0
- package/docs/v2/guide/08-migration-v1.md +68 -0
- package/docs/v2/guide/09-security-boundaries.md +28 -0
- package/docs/v2/guide/10-recovery.md +55 -0
- package/docs/v2/guide/README.md +18 -0
- package/example/.buildbeat/manifest.json +2 -2
- package/example/BUILDBEAT.md +1 -1
- package/package.json +4 -2
- package/src/constants.js +4 -1
- package/src/project.js +6 -1
- package/src/v2/adapters/mock.js +67 -0
- package/src/v2/adapters/shell.js +78 -0
- package/src/v2/cli/run.js +494 -0
- package/src/v2/domain/event-registry.js +100 -0
- package/src/v2/domain/model.js +61 -0
- package/src/v2/engine/reducer.js +253 -0
- package/src/v2/engine/risk-preset.js +48 -0
- package/src/v2/engine/workflow.js +201 -0
- package/src/v2/engine/yaml-subset.js +194 -0
- package/src/v2/evidence/collector.js +63 -0
- package/src/v2/observe/observe-config.js +194 -0
- package/src/v2/observe/observe-reducer.js +117 -0
- package/src/v2/observe/observe.js +420 -0
- package/src/v2/policy/policy.js +302 -0
- package/src/v2/presets/observe.yaml +45 -0
- package/src/v2/presets/policies/ui-render-gate.yaml +13 -0
- package/src/v2/presets/risk/controlled.yaml +39 -0
- package/src/v2/presets/risk/fast.yaml +19 -0
- package/src/v2/presets/risk/legacy-four-gates.yaml +44 -0
- package/src/v2/presets/risk/standard.yaml +28 -0
- package/src/v2/presets/software-delivery.yaml +39 -0
- package/src/v2/runtime/decisions.js +288 -0
- package/src/v2/runtime/metrics.js +140 -0
- package/src/v2/runtime/orchestrator.js +754 -0
- package/src/v2/runtime/run-record.js +55 -0
- package/src/v2/storage/event-ledger.js +154 -0
- package/src/v2/workspace/workspace-manager.js +146 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Human decision runtime: approve / reject a pending request, and the inbox.
|
|
2
|
+
// An approval is recorded only after re-reading the workspace and confirming
|
|
3
|
+
// the subject is still exactly what the request showed — if the candidate
|
|
4
|
+
// moved or the tree is dirty, the request is refreshed instead of stamped
|
|
5
|
+
// (lessons #18: no rubber-stamping a moved target). Every decision lands both
|
|
6
|
+
// as a DECISION_RECORDED event and as a line in the Git plane
|
|
7
|
+
// (delivery/work/<work>/decisions.jsonl).
|
|
8
|
+
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { evaluatePolicies, sha256Text } from "../policy/policy.js";
|
|
13
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
14
|
+
import { acquireLock, readback, releaseLock } from "../workspace/workspace-manager.js";
|
|
15
|
+
import { writeRunRecord } from "./run-record.js";
|
|
16
|
+
|
|
17
|
+
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
18
|
+
|
|
19
|
+
export class DecisionError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "DecisionError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ledgerPathFor(repoRoot, runId) {
|
|
27
|
+
return join(repoRoot, ".buildbeat", "runtime", "runs", runId, "events.jsonl");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function openWaiting(repoRoot, runId) {
|
|
31
|
+
const ledger = EventLedger.open(ledgerPathFor(repoRoot, runId));
|
|
32
|
+
if (ledger.corruption) {
|
|
33
|
+
throw new DecisionError(
|
|
34
|
+
`ledger for ${runId} is corrupted after seq=${ledger.corruption.afterSeq}; no decision can be recorded`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (!ledger.state.run) {
|
|
38
|
+
throw new DecisionError(`no ledger for run ${runId}`);
|
|
39
|
+
}
|
|
40
|
+
if (ledger.state.terminal) {
|
|
41
|
+
throw new DecisionError(`run ${runId} is already terminal (${ledger.state.terminal.status})`);
|
|
42
|
+
}
|
|
43
|
+
if (!ledger.state.pendingHuman) {
|
|
44
|
+
throw new DecisionError(`run ${runId} has no pending human request`);
|
|
45
|
+
}
|
|
46
|
+
return ledger;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function recordDecisionFile(repoRoot, work, line) {
|
|
50
|
+
const dir = join(repoRoot, "delivery", "work", work);
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
appendFileSync(join(dir, "decisions.jsonl"), `${JSON.stringify(line)}\n`, "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function approveRun(repoRoot, runId, { by = "human", transition, ts, policies } = {}) {
|
|
56
|
+
const ledger = openWaiting(repoRoot, runId);
|
|
57
|
+
const pending = ledger.state.pendingHuman;
|
|
58
|
+
if (!transition) {
|
|
59
|
+
throw new DecisionError(
|
|
60
|
+
`an approval must name its transition explicitly (pending: ${pending.transition})`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (transition !== pending.transition) {
|
|
64
|
+
throw new DecisionError(
|
|
65
|
+
`transition mismatch: pending is ${pending.transition}, got ${transition}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
acquireLock(repoRoot, runId);
|
|
69
|
+
try {
|
|
70
|
+
const bound = ledger.state.workspaces[runId];
|
|
71
|
+
if (!bound || !existsSync(bound.worktreePath)) {
|
|
72
|
+
throw new DecisionError(`worktree missing for ${runId}; cannot verify the approval subject`);
|
|
73
|
+
}
|
|
74
|
+
const tree = readback(bound.worktreePath);
|
|
75
|
+
const when = ts ?? new Date().toISOString();
|
|
76
|
+
if (tree.dirty || tree.head !== pending.subject.candidate) {
|
|
77
|
+
const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
|
|
78
|
+
ledger.append({
|
|
79
|
+
type: "HUMAN_REQUESTED",
|
|
80
|
+
actor: KERNEL,
|
|
81
|
+
ts: when,
|
|
82
|
+
data: {
|
|
83
|
+
transition: pending.transition,
|
|
84
|
+
subject: {
|
|
85
|
+
candidate: tree.head,
|
|
86
|
+
planDigest: ledger.state.run.planDigest,
|
|
87
|
+
evidenceDigest: lastEvidence?.digest ?? "UNVERIFIED",
|
|
88
|
+
},
|
|
89
|
+
reasons: ["subject changed since the request; review the new state before approving"],
|
|
90
|
+
kind: pending.kind,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return { approved: false, refreshed: true, subject: ledger.state.pendingHuman.subject };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Transition policies are enforced at the moment of stamping: an
|
|
97
|
+
// approval that a LOCAL_ENFORCED policy forbids is refused, not logged.
|
|
98
|
+
const policyRows = evaluatePolicies(
|
|
99
|
+
policies ?? [],
|
|
100
|
+
{ type: "transition", appliesTo: pending.transition },
|
|
101
|
+
{
|
|
102
|
+
state: ledger.state,
|
|
103
|
+
candidate: pending.subject.candidate,
|
|
104
|
+
workDir: join(repoRoot, "delivery", "work", ledger.state.run.work),
|
|
105
|
+
worktreePath: bound.worktreePath,
|
|
106
|
+
readWorktree: () => readback(bound.worktreePath),
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
for (const row of policyRows) {
|
|
110
|
+
ledger.append({
|
|
111
|
+
type: "POLICY_EVALUATED",
|
|
112
|
+
actor: KERNEL,
|
|
113
|
+
ts: when,
|
|
114
|
+
data: {
|
|
115
|
+
policy: row.policy,
|
|
116
|
+
phase: "transition",
|
|
117
|
+
result: row.result,
|
|
118
|
+
enforcement: row.enforcement,
|
|
119
|
+
reason: row.reason,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const refused = policyRows.filter(
|
|
124
|
+
(row) => row.enforcement !== "ADVISORY" && row.result !== "PASS",
|
|
125
|
+
);
|
|
126
|
+
if (refused.length > 0) {
|
|
127
|
+
throw new DecisionError(
|
|
128
|
+
`approval refused by policy: ${refused
|
|
129
|
+
.map((row) => `${row.policy} (${row.reason})`)
|
|
130
|
+
.join("; ")}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const warnings = policyRows
|
|
134
|
+
.filter((row) => row.enforcement === "ADVISORY" && row.result !== "PASS")
|
|
135
|
+
.map((row) => `${row.policy}: ${row.reason}`);
|
|
136
|
+
|
|
137
|
+
const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
|
|
138
|
+
ledger.append({
|
|
139
|
+
type: "DECISION_RECORDED",
|
|
140
|
+
actor: { kind: "human", id: by },
|
|
141
|
+
ts: when,
|
|
142
|
+
data: {
|
|
143
|
+
decision: "approved",
|
|
144
|
+
transition: pending.transition,
|
|
145
|
+
subject: pending.subject,
|
|
146
|
+
decisionRef,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
recordDecisionFile(repoRoot, ledger.state.run.work, {
|
|
150
|
+
ts: when,
|
|
151
|
+
run: runId,
|
|
152
|
+
decisionRef,
|
|
153
|
+
decision: "approved",
|
|
154
|
+
transition: pending.transition,
|
|
155
|
+
subject: pending.subject,
|
|
156
|
+
by,
|
|
157
|
+
});
|
|
158
|
+
let terminal = false;
|
|
159
|
+
if (pending.kind === "final-decision") {
|
|
160
|
+
ledger.append({
|
|
161
|
+
type: "RUN_TERMINAL",
|
|
162
|
+
actor: KERNEL,
|
|
163
|
+
ts: when,
|
|
164
|
+
data: {
|
|
165
|
+
status: "SUCCEEDED",
|
|
166
|
+
reason: "final decision approved; the external action (merge) stays manual",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
writeRunRecord({ repoRoot, ledger, ts: when });
|
|
170
|
+
terminal = true;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
approved: true,
|
|
174
|
+
decisionRef,
|
|
175
|
+
terminal,
|
|
176
|
+
transition: pending.transition,
|
|
177
|
+
subject: pending.subject,
|
|
178
|
+
warnings,
|
|
179
|
+
state: ledger.state,
|
|
180
|
+
};
|
|
181
|
+
} finally {
|
|
182
|
+
releaseLock(repoRoot, runId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Accepts a work artifact (plan, intent, spec) by binding a decision to the
|
|
187
|
+
// file's current digest in the Git plane. If the file changes afterwards,
|
|
188
|
+
// artifact.accepted evaluates false again — acceptance cannot go stale
|
|
189
|
+
// silently (closes MVP DoD #1 with staleness included).
|
|
190
|
+
export function acceptArtifact(repoRoot, workId, artifact, { by = "human", ts } = {}) {
|
|
191
|
+
const filePath = join(repoRoot, "delivery", "work", workId, `${artifact}.md`);
|
|
192
|
+
if (!existsSync(filePath)) {
|
|
193
|
+
throw new DecisionError(`artifact file missing: ${filePath}`);
|
|
194
|
+
}
|
|
195
|
+
const digest = sha256Text(readFileSync(filePath, "utf8"));
|
|
196
|
+
const decisionsPath = join(repoRoot, "delivery", "work", workId, "decisions.jsonl");
|
|
197
|
+
const count = existsSync(decisionsPath)
|
|
198
|
+
? readFileSync(decisionsPath, "utf8").split("\n").filter(Boolean).length
|
|
199
|
+
: 0;
|
|
200
|
+
const when = ts ?? new Date().toISOString();
|
|
201
|
+
const decisionRef = `A-${workId}-${count + 1}`;
|
|
202
|
+
recordDecisionFile(repoRoot, workId, {
|
|
203
|
+
ts: when,
|
|
204
|
+
decisionRef,
|
|
205
|
+
decision: "approved",
|
|
206
|
+
transition: `accept-${artifact}`,
|
|
207
|
+
subject: { artifact, digest },
|
|
208
|
+
by,
|
|
209
|
+
});
|
|
210
|
+
return { decisionRef, digest };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function rejectRun(repoRoot, runId, { by = "human", transition, reason, ts } = {}) {
|
|
214
|
+
const ledger = openWaiting(repoRoot, runId);
|
|
215
|
+
const pending = ledger.state.pendingHuman;
|
|
216
|
+
if (transition && transition !== pending.transition) {
|
|
217
|
+
throw new DecisionError(
|
|
218
|
+
`transition mismatch: pending is ${pending.transition}, got ${transition}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
acquireLock(repoRoot, runId);
|
|
222
|
+
try {
|
|
223
|
+
const when = ts ?? new Date().toISOString();
|
|
224
|
+
const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
|
|
225
|
+
ledger.append({
|
|
226
|
+
type: "DECISION_RECORDED",
|
|
227
|
+
actor: { kind: "human", id: by },
|
|
228
|
+
ts: when,
|
|
229
|
+
data: {
|
|
230
|
+
decision: "rejected",
|
|
231
|
+
transition: pending.transition,
|
|
232
|
+
subject: pending.subject,
|
|
233
|
+
decisionRef,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
recordDecisionFile(repoRoot, ledger.state.run.work, {
|
|
237
|
+
ts: when,
|
|
238
|
+
run: runId,
|
|
239
|
+
decisionRef,
|
|
240
|
+
decision: "rejected",
|
|
241
|
+
transition: pending.transition,
|
|
242
|
+
subject: pending.subject,
|
|
243
|
+
by,
|
|
244
|
+
reason: reason ?? null,
|
|
245
|
+
});
|
|
246
|
+
ledger.append({
|
|
247
|
+
type: "RUN_TERMINAL",
|
|
248
|
+
actor: KERNEL,
|
|
249
|
+
ts: when,
|
|
250
|
+
data: { status: "CANCELLED", reason: `rejected by ${by}${reason ? `: ${reason}` : ""}` },
|
|
251
|
+
});
|
|
252
|
+
writeRunRecord({ repoRoot, ledger, ts: when });
|
|
253
|
+
return { rejected: true, decisionRef, state: ledger.state };
|
|
254
|
+
} finally {
|
|
255
|
+
releaseLock(repoRoot, runId);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function listInbox(repoRoot) {
|
|
260
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
261
|
+
if (!existsSync(runsDir)) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
const rows = [];
|
|
265
|
+
for (const entry of readdirSync(runsDir)) {
|
|
266
|
+
const ledgerPath = join(runsDir, entry, "events.jsonl");
|
|
267
|
+
if (!existsSync(ledgerPath)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
271
|
+
if (ledger.corruption) {
|
|
272
|
+
rows.push({ run: entry, corrupted: ledger.corruption });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const state = ledger.state;
|
|
276
|
+
if (state.run && state.run.status === "WAITING_HUMAN" && state.pendingHuman) {
|
|
277
|
+
rows.push({
|
|
278
|
+
run: state.run.id,
|
|
279
|
+
work: state.run.work,
|
|
280
|
+
transition: state.pendingHuman.transition,
|
|
281
|
+
kind: state.pendingHuman.kind,
|
|
282
|
+
reasons: state.pendingHuman.reasons,
|
|
283
|
+
subject: state.pendingHuman.subject,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return rows;
|
|
288
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// buildbeat metrics v0: local, read-only, derived entirely from run ledgers.
|
|
2
|
+
// No collection, no upload (V2-PLAN §6 / lessons #8: without numbers you are
|
|
3
|
+
// forever doing precise work on the wrong thing).
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
9
|
+
|
|
10
|
+
export function computeMetrics(repoRoot) {
|
|
11
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
12
|
+
const ledgers = [];
|
|
13
|
+
if (existsSync(runsDir)) {
|
|
14
|
+
for (const entry of readdirSync(runsDir).sort()) {
|
|
15
|
+
const ledgerPath = join(runsDir, entry, "events.jsonl");
|
|
16
|
+
if (!existsSync(ledgerPath)) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
20
|
+
if (ledger.state.run || ledger.corruption) {
|
|
21
|
+
ledgers.push(ledger);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const summary = {
|
|
27
|
+
runs: ledgers.length,
|
|
28
|
+
corrupted: 0,
|
|
29
|
+
terminal: {},
|
|
30
|
+
waitingHuman: 0,
|
|
31
|
+
running: 0,
|
|
32
|
+
autoReachedHumanRate: null,
|
|
33
|
+
fixAttempts: {},
|
|
34
|
+
approvalWaitMs: [],
|
|
35
|
+
evidenceCompleteness: null,
|
|
36
|
+
finishedSteps: 0,
|
|
37
|
+
staleApprovals: 0,
|
|
38
|
+
budgetStops: 0,
|
|
39
|
+
};
|
|
40
|
+
let autoReached = 0;
|
|
41
|
+
let evidencedSteps = 0;
|
|
42
|
+
|
|
43
|
+
for (const ledger of ledgers) {
|
|
44
|
+
if (ledger.corruption) {
|
|
45
|
+
summary.corrupted += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const state = ledger.state;
|
|
49
|
+
if (state.terminal) {
|
|
50
|
+
summary.terminal[state.terminal.status] = (summary.terminal[state.terminal.status] ?? 0) + 1;
|
|
51
|
+
} else if (state.run.status === "WAITING_HUMAN") {
|
|
52
|
+
summary.waitingHuman += 1;
|
|
53
|
+
} else {
|
|
54
|
+
summary.running += 1;
|
|
55
|
+
}
|
|
56
|
+
const events = ledger.events;
|
|
57
|
+
if (events.some((event) => event.type === "HUMAN_REQUESTED")) {
|
|
58
|
+
autoReached += 1;
|
|
59
|
+
}
|
|
60
|
+
const fixAttempts = state.steps.fix?.attempts ?? 0;
|
|
61
|
+
summary.fixAttempts[fixAttempts] = (summary.fixAttempts[fixAttempts] ?? 0) + 1;
|
|
62
|
+
|
|
63
|
+
let lastRequest = null;
|
|
64
|
+
for (const event of events) {
|
|
65
|
+
if (event.type === "HUMAN_REQUESTED") {
|
|
66
|
+
lastRequest = event;
|
|
67
|
+
if (event.data.reasons?.some((reason) => reason.includes("budget"))) {
|
|
68
|
+
summary.budgetStops += 1;
|
|
69
|
+
}
|
|
70
|
+
} else if (event.type === "DECISION_RECORDED" && lastRequest) {
|
|
71
|
+
summary.approvalWaitMs.push(Date.parse(event.ts) - Date.parse(lastRequest.ts));
|
|
72
|
+
lastRequest = null;
|
|
73
|
+
} else if (event.type === "APPROVAL_STALE") {
|
|
74
|
+
summary.staleApprovals += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
79
|
+
const event = events[index];
|
|
80
|
+
if (event.type !== "STEP_FINISHED") {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
summary.finishedSteps += 1;
|
|
84
|
+
let evidenced = false;
|
|
85
|
+
for (let back = index - 1; back >= 0; back -= 1) {
|
|
86
|
+
const prior = events[back];
|
|
87
|
+
if (
|
|
88
|
+
prior.type === "STEP_STARTED" &&
|
|
89
|
+
prior.data.step === event.data.step &&
|
|
90
|
+
prior.data.attempt === event.data.attempt
|
|
91
|
+
) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (prior.type === "EVIDENCE_RECORDED") {
|
|
95
|
+
evidenced = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (evidenced) {
|
|
99
|
+
evidencedSteps += 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const measurable = summary.runs - summary.corrupted;
|
|
105
|
+
summary.autoReachedHumanRate = measurable === 0 ? null : autoReached / measurable;
|
|
106
|
+
summary.evidenceCompleteness =
|
|
107
|
+
summary.finishedSteps === 0 ? null : evidencedSteps / summary.finishedSteps;
|
|
108
|
+
return summary;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pct = (value) => (value === null ? "n/a" : `${(value * 100).toFixed(1)}%`);
|
|
112
|
+
|
|
113
|
+
export function renderMetrics(summary) {
|
|
114
|
+
const lines = [];
|
|
115
|
+
lines.push(`runs: ${summary.runs} (corrupted ledgers: ${summary.corrupted})`);
|
|
116
|
+
const terminal = Object.entries(summary.terminal)
|
|
117
|
+
.map(([status, count]) => `${status}=${count}`)
|
|
118
|
+
.join(" ");
|
|
119
|
+
lines.push(
|
|
120
|
+
`states: waiting-human=${summary.waitingHuman} running=${summary.running} ${terminal}`.trim(),
|
|
121
|
+
);
|
|
122
|
+
lines.push(`auto-reached WAITING_HUMAN: ${pct(summary.autoReachedHumanRate)}`);
|
|
123
|
+
lines.push(
|
|
124
|
+
`evidence completeness: ${pct(summary.evidenceCompleteness)} over ${summary.finishedSteps} finished steps`,
|
|
125
|
+
);
|
|
126
|
+
const fixes = Object.entries(summary.fixAttempts)
|
|
127
|
+
.map(([attempts, count]) => `${attempts}x${count}`)
|
|
128
|
+
.join(" ");
|
|
129
|
+
lines.push(`fix attempts distribution: ${fixes || "(none)"}`);
|
|
130
|
+
if (summary.approvalWaitMs.length > 0) {
|
|
131
|
+
const avg =
|
|
132
|
+
summary.approvalWaitMs.reduce((total, value) => total + value, 0) /
|
|
133
|
+
summary.approvalWaitMs.length;
|
|
134
|
+
lines.push(`approval waits: ${summary.approvalWaitMs.length}, avg ${(avg / 1000).toFixed(1)}s`);
|
|
135
|
+
} else {
|
|
136
|
+
lines.push("approval waits: (none recorded)");
|
|
137
|
+
}
|
|
138
|
+
lines.push(`stale approvals: ${summary.staleApprovals}; budget stops: ${summary.budgetStops}`);
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|