@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,754 @@
|
|
|
1
|
+
// M2 orchestrator: one project, one repository, one foreground run. Drives
|
|
2
|
+
// workflow steps through adapters, records everything in the event ledger,
|
|
3
|
+
// and stops honestly wherever automation ends — a terminal step, a human
|
|
4
|
+
// boundary (stopAt), a missing adapter, an exhausted budget, or a repeated
|
|
5
|
+
// failure fingerprint. Review steps are read-only enforced (a reviewer that
|
|
6
|
+
// writes is blocked, not merged) and consume a worker output envelope whose
|
|
7
|
+
// P0/P1 findings route back to fix. resumeRun recovers a crashed run from
|
|
8
|
+
// the ledger alone — an in-flight step closes as crashed, never silently
|
|
9
|
+
// continues — and continues an approved run only after re-verifying that the
|
|
10
|
+
// approval's subject (candidate, plan) is still what the human saw;
|
|
11
|
+
// anything changed goes APPROVAL_STALE and back to WAITING_HUMAN.
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { nextStep } from "../engine/workflow.js";
|
|
18
|
+
import { collectCommandEvidence } from "../evidence/collector.js";
|
|
19
|
+
import { evaluatePolicies } from "../policy/policy.js";
|
|
20
|
+
import { EventLedger, canonicalJson } from "../storage/event-ledger.js";
|
|
21
|
+
import {
|
|
22
|
+
acquireLock,
|
|
23
|
+
createWorkspace,
|
|
24
|
+
listChangedPaths,
|
|
25
|
+
readback,
|
|
26
|
+
releaseLock,
|
|
27
|
+
} from "../workspace/workspace-manager.js";
|
|
28
|
+
import { writeRunRecord } from "./run-record.js";
|
|
29
|
+
|
|
30
|
+
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
31
|
+
|
|
32
|
+
export class OrchestratorError extends Error {
|
|
33
|
+
constructor(message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "OrchestratorError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sha256(text) {
|
|
40
|
+
return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MVP is single project, single active run: driving a run takes a
|
|
44
|
+
// repository-wide lock in addition to the per-run lock.
|
|
45
|
+
const ACTIVE_LOCK = "active-run";
|
|
46
|
+
|
|
47
|
+
function lockActive(repoRoot) {
|
|
48
|
+
try {
|
|
49
|
+
acquireLock(repoRoot, ACTIVE_LOCK);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new OrchestratorError(
|
|
52
|
+
"another run is active in this repository (MVP allows a single active run)",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function withRunLocks(repoRoot, runId, fn) {
|
|
58
|
+
lockActive(repoRoot);
|
|
59
|
+
try {
|
|
60
|
+
acquireLock(repoRoot, runId);
|
|
61
|
+
try {
|
|
62
|
+
return fn();
|
|
63
|
+
} finally {
|
|
64
|
+
releaseLock(repoRoot, runId);
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
releaseLock(repoRoot, ACTIVE_LOCK);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseEnvelope(raw) {
|
|
72
|
+
if (raw === undefined || raw === null) {
|
|
73
|
+
return { envelope: null, error: null };
|
|
74
|
+
}
|
|
75
|
+
let doc = raw;
|
|
76
|
+
if (typeof raw === "string") {
|
|
77
|
+
// Agents habitually wrap JSON in markdown fences; strip one outer fence
|
|
78
|
+
// but stay strict about the JSON inside.
|
|
79
|
+
let text = raw.trim();
|
|
80
|
+
const fenced = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n?```$/);
|
|
81
|
+
if (fenced) {
|
|
82
|
+
text = fenced[1].trim();
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
doc = JSON.parse(text);
|
|
86
|
+
} catch {
|
|
87
|
+
return { envelope: null, error: "worker envelope is not valid JSON" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
91
|
+
return { envelope: null, error: "worker envelope must be a JSON object" };
|
|
92
|
+
}
|
|
93
|
+
if (doc.findings !== undefined) {
|
|
94
|
+
if (!Array.isArray(doc.findings)) {
|
|
95
|
+
return { envelope: null, error: "envelope findings must be an array" };
|
|
96
|
+
}
|
|
97
|
+
for (const finding of doc.findings) {
|
|
98
|
+
if (
|
|
99
|
+
!finding ||
|
|
100
|
+
typeof finding !== "object" ||
|
|
101
|
+
!/^P[0-3]$/.test(finding.severity ?? "") ||
|
|
102
|
+
typeof finding.summary !== "string"
|
|
103
|
+
) {
|
|
104
|
+
return { envelope: null, error: "each finding needs severity P0-P3 and a summary" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { envelope: doc, error: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function makeContext(options, ledger, workspace) {
|
|
112
|
+
const {
|
|
113
|
+
repoRoot,
|
|
114
|
+
workflow,
|
|
115
|
+
stopAt = [],
|
|
116
|
+
adapters = {},
|
|
117
|
+
maxAttemptsPerStep = 4,
|
|
118
|
+
stepTimeoutMs,
|
|
119
|
+
now = () => new Date().toISOString(),
|
|
120
|
+
} = options;
|
|
121
|
+
const runtimeDir = join(repoRoot, ".buildbeat", "runtime");
|
|
122
|
+
const context = {
|
|
123
|
+
repoRoot,
|
|
124
|
+
workflow,
|
|
125
|
+
stopAt,
|
|
126
|
+
adapters,
|
|
127
|
+
maxAttemptsPerStep,
|
|
128
|
+
stepTimeoutMs,
|
|
129
|
+
now,
|
|
130
|
+
runtimeDir,
|
|
131
|
+
ledger,
|
|
132
|
+
workspace,
|
|
133
|
+
};
|
|
134
|
+
context.maxAttemptsFor = (step) =>
|
|
135
|
+
workflow.budgets?.maxAttempts?.[step] ?? maxAttemptsPerStep;
|
|
136
|
+
context.policies = options.policies ?? [];
|
|
137
|
+
context.allowedPaths = options.allowedPaths ?? null;
|
|
138
|
+
context.policyCtx = () => ({
|
|
139
|
+
state: ledger.state,
|
|
140
|
+
candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
|
|
141
|
+
workDir: join(repoRoot, "delivery", "work", ledger.state.run?.work ?? ""),
|
|
142
|
+
worktreePath: workspace.worktreePath,
|
|
143
|
+
readWorktree: () =>
|
|
144
|
+
existsSync(workspace.worktreePath) ? readback(workspace.worktreePath) : null,
|
|
145
|
+
});
|
|
146
|
+
context.subjectNow = () => {
|
|
147
|
+
const candidate = ledger.state.workspaces[workspace.workspaceId]?.candidate ?? workspace.base;
|
|
148
|
+
const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
|
|
149
|
+
return {
|
|
150
|
+
candidate,
|
|
151
|
+
planDigest: ledger.state.run?.planDigest ?? "UNVERIFIED",
|
|
152
|
+
evidenceDigest: lastEvidence?.digest ?? "UNVERIFIED",
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
context.waitHuman = (transition, reasons, kind = "boundary") => {
|
|
156
|
+
ledger.append({
|
|
157
|
+
type: "HUMAN_REQUESTED",
|
|
158
|
+
actor: KERNEL,
|
|
159
|
+
ts: context.now(),
|
|
160
|
+
data: { transition, subject: context.subjectNow(), reasons, kind },
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
return context;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Evaluates configured policies of `type` for `appliesTo`, records every
|
|
167
|
+
// verdict as a POLICY_EVALUATED event, and reports what the kernel must do.
|
|
168
|
+
// ADVISORY failures are recorded but never gate (doctor reports the gap).
|
|
169
|
+
function runPolicyGate(context, type, appliesTo) {
|
|
170
|
+
const rows = evaluatePolicies(context.policies, { type, appliesTo }, context.policyCtx());
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
context.ledger.append({
|
|
173
|
+
type: "POLICY_EVALUATED",
|
|
174
|
+
actor: KERNEL,
|
|
175
|
+
ts: context.now(),
|
|
176
|
+
data: {
|
|
177
|
+
policy: row.policy,
|
|
178
|
+
phase: type,
|
|
179
|
+
result: row.result,
|
|
180
|
+
enforcement: row.enforcement,
|
|
181
|
+
reason: row.reason,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const enforced = rows.filter((row) => row.enforcement !== "ADVISORY" && row.result !== "PASS");
|
|
186
|
+
if (enforced.length === 0) {
|
|
187
|
+
return { action: "continue", rows };
|
|
188
|
+
}
|
|
189
|
+
if (enforced.some((row) => row.result === "BLOCK")) {
|
|
190
|
+
return { action: "block", rows: enforced };
|
|
191
|
+
}
|
|
192
|
+
return { action: "wait", rows: enforced };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function policyReasons(rows) {
|
|
196
|
+
return rows.map((row) => `policy ${row.policy}: ${row.reason}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Records fingerprint/policy/transition bookkeeping for a finished step and
|
|
200
|
+
// returns the next step id, or null when the run stopped (human or terminal).
|
|
201
|
+
function settleOutcome(context, step, outcome, tree, exec) {
|
|
202
|
+
const { ledger, workflow, workspace, now } = context;
|
|
203
|
+
if (outcome === "failed") {
|
|
204
|
+
ledger.append({
|
|
205
|
+
type: "FAILURE_FINGERPRINT",
|
|
206
|
+
actor: KERNEL,
|
|
207
|
+
ts: now(),
|
|
208
|
+
data: {
|
|
209
|
+
step,
|
|
210
|
+
command: exec.command,
|
|
211
|
+
exitCode: exec.exitCode ?? -1,
|
|
212
|
+
errorDigest: sha256(`${exec.stderr}\n${exec.stdout}\n${exec.exitCode}`),
|
|
213
|
+
diffDigest: sha256(tree.head),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
if (ledger.state.consecutiveSameFailure >= 2) {
|
|
217
|
+
context.waitHuman(`resume-${step}`, [
|
|
218
|
+
`same failure fingerprint twice in a row at ${step}; automation stops`,
|
|
219
|
+
]);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const to = nextStep(workflow, step, outcome);
|
|
224
|
+
let result = "PASS";
|
|
225
|
+
if (to && outcome === "failed") {
|
|
226
|
+
result = "RETRY";
|
|
227
|
+
} else if (to && outcome === "findings-blocking") {
|
|
228
|
+
result = "ROUTE";
|
|
229
|
+
} else if (!to) {
|
|
230
|
+
result = "BLOCK";
|
|
231
|
+
}
|
|
232
|
+
ledger.append({
|
|
233
|
+
type: "POLICY_EVALUATED",
|
|
234
|
+
actor: KERNEL,
|
|
235
|
+
ts: now(),
|
|
236
|
+
data: {
|
|
237
|
+
policy: "workflow.edge",
|
|
238
|
+
phase: "transition",
|
|
239
|
+
result,
|
|
240
|
+
enforcement: "LOCAL_ENFORCED",
|
|
241
|
+
reason: to ? `(${step}, ${outcome}) -> ${to}` : `no transition for (${step}, ${outcome})`,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
if (!to) {
|
|
245
|
+
ledger.append({
|
|
246
|
+
type: "RUN_TERMINAL",
|
|
247
|
+
actor: KERNEL,
|
|
248
|
+
ts: now(),
|
|
249
|
+
data: { status: "FAILED", reason: `no transition for (${step}, ${outcome})` },
|
|
250
|
+
});
|
|
251
|
+
writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
ledger.append({
|
|
255
|
+
type: "TRANSITION",
|
|
256
|
+
actor: KERNEL,
|
|
257
|
+
ts: now(),
|
|
258
|
+
data: { from: step, to, cause: `outcome:${outcome}` },
|
|
259
|
+
});
|
|
260
|
+
ledger.append({
|
|
261
|
+
type: "CHECKPOINT",
|
|
262
|
+
actor: KERNEL,
|
|
263
|
+
ts: now(),
|
|
264
|
+
data: {
|
|
265
|
+
resumePoint: { step: to, attempt: (ledger.state.steps[to]?.attempts ?? 0) + 1 },
|
|
266
|
+
workspaceStates: [{ workspaceId: workspace.workspaceId, head: tree.head, dirty: tree.dirty }],
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
return to;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
273
|
+
const { ledger, workflow, workspace, adapters, now } = context;
|
|
274
|
+
let step = startStep;
|
|
275
|
+
let firstStep = true;
|
|
276
|
+
while (step) {
|
|
277
|
+
if (workflow.terminal.has(step)) {
|
|
278
|
+
context.waitHuman(
|
|
279
|
+
`enter-${step}`,
|
|
280
|
+
["terminal step requires a human decision"],
|
|
281
|
+
"final-decision",
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (context.stopAt.includes(step) && !(skipBoundaryOnce && firstStep)) {
|
|
286
|
+
context.waitHuman(`enter-${step}`, [`automation boundary: stopAt includes ${step}`]);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
firstStep = false;
|
|
290
|
+
const stepDef = workflow.steps.find((candidate) => candidate.id === step);
|
|
291
|
+
const adapter = stepDef.worker ? adapters[stepDef.worker] : null;
|
|
292
|
+
if (!adapter) {
|
|
293
|
+
context.waitHuman(`enter-${step}`, [
|
|
294
|
+
`no adapter configured for worker ${stepDef.worker ?? "(none)"}; attended handoff`,
|
|
295
|
+
]);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const preGate = runPolicyGate(context, "pre", step);
|
|
300
|
+
if (preGate.action === "block") {
|
|
301
|
+
ledger.append({
|
|
302
|
+
type: "RUN_TERMINAL",
|
|
303
|
+
actor: KERNEL,
|
|
304
|
+
ts: now(),
|
|
305
|
+
data: { status: "FAILED", reason: `pre policy blocked ${step}` },
|
|
306
|
+
});
|
|
307
|
+
writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (preGate.action === "wait") {
|
|
311
|
+
context.waitHuman(`resume-${step}`, policyReasons(preGate.rows));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const attempt = (ledger.state.steps[step]?.attempts ?? 0) + 1;
|
|
316
|
+
const maxAttempts = context.maxAttemptsFor(step);
|
|
317
|
+
if (attempt > maxAttempts) {
|
|
318
|
+
context.waitHuman(`resume-${step}`, [
|
|
319
|
+
`budget exhausted: ${step} would exceed maxAttempts=${maxAttempts}`,
|
|
320
|
+
]);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
ledger.append({
|
|
325
|
+
type: "STEP_STARTED",
|
|
326
|
+
actor: KERNEL,
|
|
327
|
+
ts: now(),
|
|
328
|
+
data: {
|
|
329
|
+
step,
|
|
330
|
+
attempt,
|
|
331
|
+
worker: stepDef.worker,
|
|
332
|
+
adapter: adapter.name,
|
|
333
|
+
workspaceId: workspace.workspaceId,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
const before = stepDef.readonly ? readback(workspace.worktreePath) : null;
|
|
337
|
+
const outputsDir = join(context.runtimeDir, "runs", ledger.state.run.id, "outputs");
|
|
338
|
+
mkdirSync(outputsDir, { recursive: true });
|
|
339
|
+
const outputPath = join(outputsDir, `${step}-${attempt}.json`);
|
|
340
|
+
const exec = adapter.execute({
|
|
341
|
+
step,
|
|
342
|
+
worker: stepDef.worker,
|
|
343
|
+
workspacePath: workspace.worktreePath,
|
|
344
|
+
input: { workId: ledger.state.run.work, runId: ledger.state.run.id, step, attempt },
|
|
345
|
+
timeoutMs: context.stepTimeoutMs,
|
|
346
|
+
outputPath,
|
|
347
|
+
});
|
|
348
|
+
const tree = readback(workspace.worktreePath);
|
|
349
|
+
const evidence = collectCommandEvidence({
|
|
350
|
+
runtimeDir: context.runtimeDir,
|
|
351
|
+
runId: ledger.state.run.id,
|
|
352
|
+
step,
|
|
353
|
+
attempt,
|
|
354
|
+
execResult: exec,
|
|
355
|
+
subject: tree.head,
|
|
356
|
+
});
|
|
357
|
+
ledger.append({
|
|
358
|
+
type: "EVIDENCE_RECORDED",
|
|
359
|
+
actor: KERNEL,
|
|
360
|
+
ts: now(),
|
|
361
|
+
data: {
|
|
362
|
+
evidenceRef: evidence.location,
|
|
363
|
+
kind: evidence.kind,
|
|
364
|
+
subject: evidence.subject,
|
|
365
|
+
digest: evidence.digest,
|
|
366
|
+
status: evidence.status,
|
|
367
|
+
grade: evidence.grade,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Read-only enforcement: a reviewer that changed the workspace is a
|
|
372
|
+
// policy violation, not a candidate (invariants 9/17).
|
|
373
|
+
if (stepDef.readonly && (tree.head !== before.head || tree.dirty !== before.dirty)) {
|
|
374
|
+
ledger.append({
|
|
375
|
+
type: "POLICY_EVALUATED",
|
|
376
|
+
actor: KERNEL,
|
|
377
|
+
ts: now(),
|
|
378
|
+
data: {
|
|
379
|
+
policy: "step.readonly",
|
|
380
|
+
phase: "action",
|
|
381
|
+
result: "BLOCK",
|
|
382
|
+
enforcement: "LOCAL_ENFORCED",
|
|
383
|
+
reason: `read-only step ${step} modified the workspace`,
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
ledger.append({
|
|
387
|
+
type: "STEP_FINISHED",
|
|
388
|
+
actor: KERNEL,
|
|
389
|
+
ts: now(),
|
|
390
|
+
data: { step, attempt, status: "blocked" },
|
|
391
|
+
});
|
|
392
|
+
context.waitHuman(`resume-${step}`, [
|
|
393
|
+
`read-only step ${step} modified the workspace; human triage required`,
|
|
394
|
+
]);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let envelopeRaw = exec.envelope;
|
|
399
|
+
if (exec.envelope !== undefined && exec.envelope !== null) {
|
|
400
|
+
writeFileSync(outputPath, `${JSON.stringify(exec.envelope, null, 2)}\n`, "utf8");
|
|
401
|
+
} else if (existsSync(outputPath)) {
|
|
402
|
+
envelopeRaw = readFileSync(outputPath, "utf8");
|
|
403
|
+
}
|
|
404
|
+
const { envelope, error: envelopeError } = parseEnvelope(envelopeRaw);
|
|
405
|
+
|
|
406
|
+
let stepStatus;
|
|
407
|
+
if (exec.spawnError) {
|
|
408
|
+
stepStatus = "crashed";
|
|
409
|
+
} else if (exec.timedOut) {
|
|
410
|
+
stepStatus = "timeout";
|
|
411
|
+
} else if (exec.signal) {
|
|
412
|
+
stepStatus = "crashed";
|
|
413
|
+
} else if (exec.exitCode !== 0) {
|
|
414
|
+
stepStatus = "failed";
|
|
415
|
+
} else if (envelopeError) {
|
|
416
|
+
stepStatus = "invalid-output";
|
|
417
|
+
} else {
|
|
418
|
+
stepStatus = "succeeded";
|
|
419
|
+
}
|
|
420
|
+
ledger.append({
|
|
421
|
+
type: "STEP_FINISHED",
|
|
422
|
+
actor: KERNEL,
|
|
423
|
+
ts: now(),
|
|
424
|
+
data: { step, attempt, status: stepStatus, exitCode: exec.exitCode },
|
|
425
|
+
});
|
|
426
|
+
ledger.append({
|
|
427
|
+
type: "BUDGET_CONSUMED",
|
|
428
|
+
actor: KERNEL,
|
|
429
|
+
ts: now(),
|
|
430
|
+
data: { kind: "attempts", amount: 1, remaining: maxAttempts - attempt },
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
let blockingFindings = [];
|
|
434
|
+
if (envelope?.findings) {
|
|
435
|
+
blockingFindings = envelope.findings.filter(
|
|
436
|
+
(finding) => finding.severity === "P0" || finding.severity === "P1",
|
|
437
|
+
);
|
|
438
|
+
ledger.append({
|
|
439
|
+
type: "EVIDENCE_RECORDED",
|
|
440
|
+
actor: KERNEL,
|
|
441
|
+
ts: now(),
|
|
442
|
+
data: {
|
|
443
|
+
evidenceRef: outputPath,
|
|
444
|
+
kind: "review",
|
|
445
|
+
subject: tree.head,
|
|
446
|
+
digest: sha256(canonicalJson(envelope)),
|
|
447
|
+
status: blockingFindings.length > 0 ? "failed" : "passed",
|
|
448
|
+
grade: "L2",
|
|
449
|
+
findings: envelope.findings,
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Scope enforcement (B §10: out-of-scope changes stop the loop): any
|
|
455
|
+
// path changed outside the allowed set means this candidate cannot
|
|
456
|
+
// proceed, whatever the exit code said.
|
|
457
|
+
if (!stepDef.readonly && context.allowedPaths) {
|
|
458
|
+
const changed = listChangedPaths(workspace.worktreePath, workspace.base);
|
|
459
|
+
const violations = changed.filter(
|
|
460
|
+
(path) =>
|
|
461
|
+
!context.allowedPaths.some(
|
|
462
|
+
(prefix) =>
|
|
463
|
+
path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
if (violations.length > 0) {
|
|
467
|
+
ledger.append({
|
|
468
|
+
type: "POLICY_EVALUATED",
|
|
469
|
+
actor: KERNEL,
|
|
470
|
+
ts: now(),
|
|
471
|
+
data: {
|
|
472
|
+
policy: "workspace.scope",
|
|
473
|
+
phase: "action",
|
|
474
|
+
result: "BLOCK",
|
|
475
|
+
enforcement: "LOCAL_ENFORCED",
|
|
476
|
+
reason: `out-of-scope changes: ${violations.slice(0, 5).join(", ")}`,
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
context.waitHuman(`resume-${step}`, [
|
|
480
|
+
`worker changed paths outside the allowed scope: ${violations.slice(0, 5).join(", ")}`,
|
|
481
|
+
]);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (stepStatus === "succeeded" && !stepDef.readonly) {
|
|
487
|
+
if (tree.dirty) {
|
|
488
|
+
context.waitHuman(`resume-${step}`, [
|
|
489
|
+
`step ${step} left a dirty worktree; a candidate must be a committed state`,
|
|
490
|
+
]);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const pinned = ledger.state.workspaces[workspace.workspaceId]?.candidate;
|
|
494
|
+
if (tree.head !== (pinned ?? workspace.base)) {
|
|
495
|
+
ledger.append({
|
|
496
|
+
type: "CANDIDATE_PINNED",
|
|
497
|
+
actor: KERNEL,
|
|
498
|
+
ts: now(),
|
|
499
|
+
data: {
|
|
500
|
+
workspaceId: workspace.workspaceId,
|
|
501
|
+
base: workspace.base,
|
|
502
|
+
candidate: tree.head,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (stepStatus === "succeeded") {
|
|
509
|
+
const postGate = runPolicyGate(context, "post", step);
|
|
510
|
+
if (postGate.action === "block") {
|
|
511
|
+
ledger.append({
|
|
512
|
+
type: "RUN_TERMINAL",
|
|
513
|
+
actor: KERNEL,
|
|
514
|
+
ts: now(),
|
|
515
|
+
data: { status: "FAILED", reason: `post policy blocked ${step}` },
|
|
516
|
+
});
|
|
517
|
+
writeRunRecord({ repoRoot: context.repoRoot, ledger, ts: now() });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (postGate.action === "wait") {
|
|
521
|
+
context.waitHuman(`resume-${step}`, policyReasons(postGate.rows));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
let outcome;
|
|
527
|
+
if (stepStatus !== "succeeded") {
|
|
528
|
+
outcome = "failed";
|
|
529
|
+
} else if (blockingFindings.length > 0) {
|
|
530
|
+
outcome = "findings-blocking";
|
|
531
|
+
} else {
|
|
532
|
+
outcome = "succeeded";
|
|
533
|
+
}
|
|
534
|
+
step = settleOutcome(context, step, outcome, tree, exec);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function openLedgerFor(repoRoot, runId) {
|
|
539
|
+
const ledgerPath = join(repoRoot, ".buildbeat", "runtime", "runs", runId, "events.jsonl");
|
|
540
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
541
|
+
if (ledger.corruption) {
|
|
542
|
+
throw new OrchestratorError(
|
|
543
|
+
`ledger for ${runId} is corrupted after seq=${ledger.corruption.afterSeq}; human decision required`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
return { ledger, ledgerPath };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function startRun(options) {
|
|
550
|
+
const {
|
|
551
|
+
repoRoot,
|
|
552
|
+
workflow,
|
|
553
|
+
workflowDigest,
|
|
554
|
+
workId,
|
|
555
|
+
runId,
|
|
556
|
+
base = "HEAD",
|
|
557
|
+
entry = workflow.entry,
|
|
558
|
+
riskPreset = "standard",
|
|
559
|
+
planDigest,
|
|
560
|
+
intentDigest,
|
|
561
|
+
} = options;
|
|
562
|
+
if (!repoRoot || !workId || !runId) {
|
|
563
|
+
throw new OrchestratorError("repoRoot, workId and runId are required");
|
|
564
|
+
}
|
|
565
|
+
if (!workflow.stepIds.has(entry)) {
|
|
566
|
+
throw new OrchestratorError(`entry step not in workflow: ${entry}`);
|
|
567
|
+
}
|
|
568
|
+
if (!workflowDigest) {
|
|
569
|
+
throw new OrchestratorError("workflowDigest is required (pin what you run)");
|
|
570
|
+
}
|
|
571
|
+
const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
|
|
572
|
+
if (ledger.events.length > 0) {
|
|
573
|
+
throw new OrchestratorError(`run ${runId} already has a ledger; use resumeRun`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return withRunLocks(repoRoot, runId, () => {
|
|
577
|
+
const workspace = createWorkspace({ repoRoot, runId, base });
|
|
578
|
+
const context = makeContext(options, ledger, workspace);
|
|
579
|
+
const now = context.now;
|
|
580
|
+
ledger.append({
|
|
581
|
+
type: "RUN_CREATED",
|
|
582
|
+
actor: KERNEL,
|
|
583
|
+
ts: now(),
|
|
584
|
+
run: runId,
|
|
585
|
+
work: workId,
|
|
586
|
+
data: {
|
|
587
|
+
workflowRef: workflow.name,
|
|
588
|
+
workflowDigest,
|
|
589
|
+
base: workspace.base,
|
|
590
|
+
riskPreset,
|
|
591
|
+
entry,
|
|
592
|
+
planDigest: planDigest ?? "UNVERIFIED",
|
|
593
|
+
intentDigest: intentDigest ?? "UNVERIFIED",
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
|
|
597
|
+
ledger.append({
|
|
598
|
+
type: "WORKSPACE_BOUND",
|
|
599
|
+
actor: KERNEL,
|
|
600
|
+
ts: now(),
|
|
601
|
+
data: {
|
|
602
|
+
workspaceId: workspace.workspaceId,
|
|
603
|
+
repo: repoRoot,
|
|
604
|
+
branch: workspace.branch,
|
|
605
|
+
worktreePath: workspace.worktreePath,
|
|
606
|
+
base: workspace.base,
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
drive(context, entry);
|
|
610
|
+
return { runId, workId, ledgerPath, state: ledger.state, workspace };
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function resumeStepFromTransition(transition) {
|
|
615
|
+
if (transition.startsWith("enter-")) {
|
|
616
|
+
return transition.slice("enter-".length);
|
|
617
|
+
}
|
|
618
|
+
if (transition.startsWith("resume-")) {
|
|
619
|
+
return transition.slice("resume-".length);
|
|
620
|
+
}
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export function resumeRun(options) {
|
|
625
|
+
const { repoRoot, workflow, workflowDigest, runId, planDigest } = options;
|
|
626
|
+
if (!repoRoot || !runId) {
|
|
627
|
+
throw new OrchestratorError("repoRoot and runId are required");
|
|
628
|
+
}
|
|
629
|
+
const { ledger, ledgerPath } = openLedgerFor(repoRoot, runId);
|
|
630
|
+
const state = ledger.state;
|
|
631
|
+
if (!state.run) {
|
|
632
|
+
throw new OrchestratorError(`no ledger for run ${runId}; use startRun`);
|
|
633
|
+
}
|
|
634
|
+
if (workflowDigest && state.run.workflowDigest !== workflowDigest) {
|
|
635
|
+
throw new OrchestratorError(
|
|
636
|
+
`workflow changed since the run was created (${state.run.workflowDigest} != ${workflowDigest}); refusing to resume`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
if (state.terminal) {
|
|
640
|
+
return { runId, ledgerPath, state, resumed: false, reason: "run is terminal" };
|
|
641
|
+
}
|
|
642
|
+
if (state.run.status === "WAITING_HUMAN" && state.pendingHuman) {
|
|
643
|
+
return { runId, ledgerPath, state, resumed: false, reason: "waiting on a human decision" };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const bound = state.workspaces[runId];
|
|
647
|
+
if (!bound) {
|
|
648
|
+
throw new OrchestratorError(`run ${runId} has no bound workspace; cannot resume`);
|
|
649
|
+
}
|
|
650
|
+
if (!existsSync(bound.worktreePath)) {
|
|
651
|
+
throw new OrchestratorError(
|
|
652
|
+
`worktree missing: ${bound.worktreePath}; recovery requires a human decision`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
const workspace = {
|
|
656
|
+
workspaceId: runId,
|
|
657
|
+
repoRoot,
|
|
658
|
+
worktreePath: bound.worktreePath,
|
|
659
|
+
branch: bound.branch,
|
|
660
|
+
base: bound.base,
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
return withRunLocks(repoRoot, runId, () => {
|
|
664
|
+
const context = makeContext(options, ledger, workspace);
|
|
665
|
+
const now = context.now;
|
|
666
|
+
|
|
667
|
+
if (state.run.status === "WAITING_HUMAN") {
|
|
668
|
+
// Pending request already resolved: continue only if the approval's
|
|
669
|
+
// subject is still exactly what the human saw (F6 machine closure).
|
|
670
|
+
const approval = [...state.approvals].reverse().find((entry) => !entry.stale);
|
|
671
|
+
if (!approval) {
|
|
672
|
+
return { runId, ledgerPath, state, resumed: false, reason: "no active approval to act on" };
|
|
673
|
+
}
|
|
674
|
+
const tree = readback(workspace.worktreePath);
|
|
675
|
+
const changed = [];
|
|
676
|
+
if (tree.head !== approval.subject.candidate) {
|
|
677
|
+
changed.push("candidate");
|
|
678
|
+
}
|
|
679
|
+
if (
|
|
680
|
+
planDigest &&
|
|
681
|
+
approval.subject.planDigest !== "UNVERIFIED" &&
|
|
682
|
+
planDigest !== approval.subject.planDigest
|
|
683
|
+
) {
|
|
684
|
+
changed.push("plan");
|
|
685
|
+
}
|
|
686
|
+
if (changed.length > 0) {
|
|
687
|
+
ledger.append({
|
|
688
|
+
type: "APPROVAL_STALE",
|
|
689
|
+
actor: KERNEL,
|
|
690
|
+
ts: now(),
|
|
691
|
+
data: { approvalRef: approval.decisionRef, changed },
|
|
692
|
+
});
|
|
693
|
+
return { runId, ledgerPath, state: ledger.state, resumed: true, stale: true, reason: null };
|
|
694
|
+
}
|
|
695
|
+
const step = resumeStepFromTransition(approval.transition);
|
|
696
|
+
if (!step || !context.workflow.stepIds.has(step)) {
|
|
697
|
+
throw new OrchestratorError(
|
|
698
|
+
`cannot derive a resume step from approved transition ${approval.transition}`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
|
|
702
|
+
drive(context, step, { skipBoundaryOnce: true });
|
|
703
|
+
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Crash recovery: the process died while RUNNING.
|
|
707
|
+
ledger.append({
|
|
708
|
+
type: "RUN_INTERRUPTED",
|
|
709
|
+
actor: KERNEL,
|
|
710
|
+
ts: now(),
|
|
711
|
+
data: { cause: "resume after process loss" },
|
|
712
|
+
});
|
|
713
|
+
const tree = readback(workspace.worktreePath);
|
|
714
|
+
let startStep = null;
|
|
715
|
+
if (state.currentStep) {
|
|
716
|
+
const step = state.currentStep;
|
|
717
|
+
const attempt = state.steps[step].attempts;
|
|
718
|
+
ledger.append({
|
|
719
|
+
type: "STEP_FINISHED",
|
|
720
|
+
actor: KERNEL,
|
|
721
|
+
ts: now(),
|
|
722
|
+
data: { step, attempt, status: "crashed" },
|
|
723
|
+
});
|
|
724
|
+
if (tree.dirty) {
|
|
725
|
+
context.waitHuman(`resume-${step}`, [
|
|
726
|
+
`interrupted step ${step} left a dirty worktree; decide whether to keep or discard before rerunning`,
|
|
727
|
+
]);
|
|
728
|
+
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
729
|
+
}
|
|
730
|
+
startStep = settleOutcome(context, step, "failed", tree, {
|
|
731
|
+
command: "(interrupted)",
|
|
732
|
+
exitCode: null,
|
|
733
|
+
stdout: "",
|
|
734
|
+
stderr: "process lost before completion",
|
|
735
|
+
});
|
|
736
|
+
} else if (tree.dirty) {
|
|
737
|
+
context.waitHuman("resume-run", [
|
|
738
|
+
"worktree is dirty at resume with no step in flight; human triage required",
|
|
739
|
+
]);
|
|
740
|
+
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
741
|
+
} else {
|
|
742
|
+
startStep = state.lastCheckpoint?.resumePoint?.step ?? state.run.entry ?? null;
|
|
743
|
+
if (!startStep) {
|
|
744
|
+
throw new OrchestratorError(
|
|
745
|
+
"no checkpoint and no recorded entry; cannot derive a safe resume point",
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (startStep) {
|
|
750
|
+
drive(context, startStep);
|
|
751
|
+
}
|
|
752
|
+
return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
|
|
753
|
+
});
|
|
754
|
+
}
|