@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,61 @@
|
|
|
1
|
+
// v2 domain constants. Source of truth: docs/v2/RFC-0002-domain-model.md and
|
|
2
|
+
// docs/v2/SPEC-0001-events-v1.md (FROZEN; additive-only evolution).
|
|
3
|
+
|
|
4
|
+
export const WORK_STATES = ["OPEN", "COMPLETED", "CANCELLED"];
|
|
5
|
+
|
|
6
|
+
export const RUN_STATES = [
|
|
7
|
+
"CREATED",
|
|
8
|
+
"QUEUED",
|
|
9
|
+
"RUNNING",
|
|
10
|
+
"WAITING_HUMAN",
|
|
11
|
+
"BLOCKED",
|
|
12
|
+
"SUCCEEDED",
|
|
13
|
+
"FAILED",
|
|
14
|
+
"CANCELLED",
|
|
15
|
+
"SUPERSEDED",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const TERMINAL_RUN_STATES = ["SUCCEEDED", "FAILED", "CANCELLED", "SUPERSEDED"];
|
|
19
|
+
|
|
20
|
+
export const STEP_STATES = [
|
|
21
|
+
"PENDING",
|
|
22
|
+
"READY",
|
|
23
|
+
"RUNNING",
|
|
24
|
+
"SUCCEEDED",
|
|
25
|
+
"FAILED",
|
|
26
|
+
"SKIPPED",
|
|
27
|
+
"CANCELLED",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const GATE_RESULTS = ["PASS", "RETRY", "ROUTE", "WAIT_HUMAN", "BLOCK", "UNVERIFIED"];
|
|
31
|
+
|
|
32
|
+
export const ACTOR_KINDS = ["kernel", "worker", "human", "adapter", "provider"];
|
|
33
|
+
|
|
34
|
+
export const POLICY_PHASES = ["pre", "post", "transition", "action"];
|
|
35
|
+
|
|
36
|
+
export const STEP_FINISH_STATUSES = [
|
|
37
|
+
"succeeded",
|
|
38
|
+
"failed",
|
|
39
|
+
"blocked",
|
|
40
|
+
"invalid-output",
|
|
41
|
+
"timeout",
|
|
42
|
+
"crashed",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const BUDGET_KINDS = ["attempts", "tokens", "cost", "time"];
|
|
46
|
+
|
|
47
|
+
export const EVIDENCE_STATUSES = ["passed", "failed", "unverified"];
|
|
48
|
+
|
|
49
|
+
export const EVIDENCE_GRADES = ["L0", "L1", "L2", "L3", "L4"];
|
|
50
|
+
|
|
51
|
+
export const DECISION_VALUES = ["approved", "rejected"];
|
|
52
|
+
|
|
53
|
+
// observe v0 per docs/v2/RFC-0003-workflow-policy.md §8 (frozen shape):
|
|
54
|
+
// bands are exactly three fixed levels; triage actions are a closed set.
|
|
55
|
+
export const OBSERVE_BAND_LEVELS = ["log", "diagnose", "intent"];
|
|
56
|
+
|
|
57
|
+
export const OBSERVE_SEVERITIES = ["info", "warn", "error", "critical"];
|
|
58
|
+
|
|
59
|
+
export const TRIAGE_ACTIONS = ["fix_now", "schedule", "dismiss"];
|
|
60
|
+
|
|
61
|
+
export const INTENT_DRAFT_STATES = ["draft", "accepted", "scheduled", "dismissed"];
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Deterministic state reducer per docs/v2/SPEC-0001-events-v1.md §5.
|
|
2
|
+
// Same ledger must always reduce to the same state; illegal sequences are
|
|
3
|
+
// reported, never repaired. Unknown event types are skipped (seq still
|
|
4
|
+
// advances) so newer writers stay readable.
|
|
5
|
+
|
|
6
|
+
export class IllegalEventError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "IllegalEventError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function initialState() {
|
|
14
|
+
return {
|
|
15
|
+
seq: 0,
|
|
16
|
+
run: null,
|
|
17
|
+
position: null,
|
|
18
|
+
currentStep: null,
|
|
19
|
+
steps: {},
|
|
20
|
+
workspaces: {},
|
|
21
|
+
evidence: [],
|
|
22
|
+
policyLog: [],
|
|
23
|
+
fingerprints: [],
|
|
24
|
+
consecutiveSameFailure: 0,
|
|
25
|
+
budgets: {},
|
|
26
|
+
pendingHuman: null,
|
|
27
|
+
decisions: [],
|
|
28
|
+
approvals: [],
|
|
29
|
+
lastCheckpoint: null,
|
|
30
|
+
interrupted: null,
|
|
31
|
+
terminal: null,
|
|
32
|
+
compacted: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RUN_STARTABLE_FROM = ["CREATED", "QUEUED", "WAITING_HUMAN", "BLOCKED"];
|
|
37
|
+
|
|
38
|
+
export function applyEvent(state, event) {
|
|
39
|
+
if (event.seq !== state.seq + 1) {
|
|
40
|
+
throw new IllegalEventError(`expected seq ${state.seq + 1}, got ${event.seq}`);
|
|
41
|
+
}
|
|
42
|
+
const { type, data } = event;
|
|
43
|
+
if (!state.run && type !== "RUN_CREATED") {
|
|
44
|
+
throw new IllegalEventError(`first event must be RUN_CREATED, got ${type}`);
|
|
45
|
+
}
|
|
46
|
+
if (state.terminal && type !== "RUN_COMPACTED") {
|
|
47
|
+
throw new IllegalEventError(
|
|
48
|
+
`run is terminal (${state.terminal.status}); only RUN_COMPACTED is allowed`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const next = structuredClone(state);
|
|
53
|
+
next.seq = event.seq;
|
|
54
|
+
|
|
55
|
+
switch (type) {
|
|
56
|
+
case "RUN_CREATED": {
|
|
57
|
+
if (state.run) {
|
|
58
|
+
throw new IllegalEventError("duplicate RUN_CREATED");
|
|
59
|
+
}
|
|
60
|
+
next.run = {
|
|
61
|
+
id: event.run,
|
|
62
|
+
work: event.work,
|
|
63
|
+
status: "CREATED",
|
|
64
|
+
workflowRef: data.workflowRef,
|
|
65
|
+
workflowDigest: data.workflowDigest,
|
|
66
|
+
base: data.base,
|
|
67
|
+
riskPreset: data.riskPreset,
|
|
68
|
+
entry: data.entry ?? null,
|
|
69
|
+
planDigest: data.planDigest ?? "UNVERIFIED",
|
|
70
|
+
intentDigest: data.intentDigest ?? "UNVERIFIED",
|
|
71
|
+
};
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "RUN_STARTED": {
|
|
75
|
+
if (!RUN_STARTABLE_FROM.includes(state.run.status)) {
|
|
76
|
+
throw new IllegalEventError(`cannot start run from status ${state.run.status}`);
|
|
77
|
+
}
|
|
78
|
+
if (state.pendingHuman) {
|
|
79
|
+
throw new IllegalEventError("pending human request unresolved");
|
|
80
|
+
}
|
|
81
|
+
next.run.status = "RUNNING";
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "WORKSPACE_BOUND": {
|
|
85
|
+
if (state.workspaces[data.workspaceId]) {
|
|
86
|
+
throw new IllegalEventError(`workspace already bound: ${data.workspaceId}`);
|
|
87
|
+
}
|
|
88
|
+
next.workspaces[data.workspaceId] = {
|
|
89
|
+
repo: data.repo,
|
|
90
|
+
branch: data.branch,
|
|
91
|
+
worktreePath: data.worktreePath,
|
|
92
|
+
base: data.base,
|
|
93
|
+
candidate: null,
|
|
94
|
+
};
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "STEP_STARTED": {
|
|
98
|
+
if (state.run.status !== "RUNNING") {
|
|
99
|
+
throw new IllegalEventError(`cannot start step while run is ${state.run.status}`);
|
|
100
|
+
}
|
|
101
|
+
if (!state.workspaces[data.workspaceId]) {
|
|
102
|
+
throw new IllegalEventError(`unknown workspace: ${data.workspaceId}`);
|
|
103
|
+
}
|
|
104
|
+
const expectedAttempt = (state.steps[data.step]?.attempts ?? 0) + 1;
|
|
105
|
+
if (data.attempt !== expectedAttempt) {
|
|
106
|
+
throw new IllegalEventError(
|
|
107
|
+
`step ${data.step} expected attempt ${expectedAttempt}, got ${data.attempt}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
next.steps[data.step] = { status: "RUNNING", attempts: data.attempt, detail: null };
|
|
111
|
+
next.currentStep = data.step;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "STEP_FINISHED": {
|
|
115
|
+
const step = state.steps[data.step];
|
|
116
|
+
if (!step || step.status !== "RUNNING" || step.attempts !== data.attempt) {
|
|
117
|
+
throw new IllegalEventError(
|
|
118
|
+
`no running attempt ${data.attempt} for step ${data.step} to finish`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
next.steps[data.step].status = data.status === "succeeded" ? "SUCCEEDED" : "FAILED";
|
|
122
|
+
next.steps[data.step].detail = data.status;
|
|
123
|
+
next.currentStep = null;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "CANDIDATE_PINNED": {
|
|
127
|
+
if (!state.workspaces[data.workspaceId]) {
|
|
128
|
+
throw new IllegalEventError(`unknown workspace: ${data.workspaceId}`);
|
|
129
|
+
}
|
|
130
|
+
next.workspaces[data.workspaceId].base = data.base;
|
|
131
|
+
next.workspaces[data.workspaceId].candidate = data.candidate;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "EVIDENCE_RECORDED": {
|
|
135
|
+
next.evidence.push({
|
|
136
|
+
ref: data.evidenceRef,
|
|
137
|
+
kind: data.kind,
|
|
138
|
+
subject: data.subject,
|
|
139
|
+
digest: data.digest,
|
|
140
|
+
status: data.status,
|
|
141
|
+
grade: data.grade,
|
|
142
|
+
...(data.findings ? { findings: data.findings } : {}),
|
|
143
|
+
});
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "POLICY_EVALUATED": {
|
|
147
|
+
next.policyLog.push({ policy: data.policy, phase: data.phase, result: data.result });
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case "TRANSITION": {
|
|
151
|
+
next.position = data.to;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "FAILURE_FINGERPRINT": {
|
|
155
|
+
const key = [data.step, data.command, data.exitCode, data.errorDigest].join("|");
|
|
156
|
+
const last = state.fingerprints[state.fingerprints.length - 1];
|
|
157
|
+
next.consecutiveSameFailure = last === key ? state.consecutiveSameFailure + 1 : 1;
|
|
158
|
+
next.fingerprints.push(key);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case "BUDGET_CONSUMED": {
|
|
162
|
+
const consumed = state.budgets[data.kind]?.consumed ?? 0;
|
|
163
|
+
next.budgets[data.kind] = { consumed: consumed + data.amount, remaining: data.remaining };
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "HUMAN_REQUESTED": {
|
|
167
|
+
next.pendingHuman = {
|
|
168
|
+
transition: data.transition,
|
|
169
|
+
subject: data.subject,
|
|
170
|
+
reasons: data.reasons,
|
|
171
|
+
kind: data.kind ?? "boundary",
|
|
172
|
+
};
|
|
173
|
+
next.run.status = "WAITING_HUMAN";
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "DECISION_RECORDED": {
|
|
177
|
+
if (!state.pendingHuman || state.pendingHuman.transition !== data.transition) {
|
|
178
|
+
throw new IllegalEventError(`no pending human request for transition ${data.transition}`);
|
|
179
|
+
}
|
|
180
|
+
next.decisions.push({
|
|
181
|
+
decision: data.decision,
|
|
182
|
+
transition: data.transition,
|
|
183
|
+
subject: data.subject,
|
|
184
|
+
decisionRef: data.decisionRef,
|
|
185
|
+
});
|
|
186
|
+
if (data.decision === "approved") {
|
|
187
|
+
next.approvals.push({
|
|
188
|
+
decisionRef: data.decisionRef,
|
|
189
|
+
transition: data.transition,
|
|
190
|
+
subject: data.subject,
|
|
191
|
+
stale: false,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
next.pendingHuman = null;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "APPROVAL_STALE": {
|
|
198
|
+
const index = state.approvals.findIndex(
|
|
199
|
+
(approval) => approval.decisionRef === data.approvalRef && !approval.stale,
|
|
200
|
+
);
|
|
201
|
+
if (index === -1) {
|
|
202
|
+
throw new IllegalEventError(`no active approval to mark stale: ${data.approvalRef}`);
|
|
203
|
+
}
|
|
204
|
+
next.approvals[index].stale = true;
|
|
205
|
+
next.approvals[index].changed = data.changed;
|
|
206
|
+
next.pendingHuman = {
|
|
207
|
+
transition: state.approvals[index].transition,
|
|
208
|
+
subject: state.approvals[index].subject,
|
|
209
|
+
reasons: ["APPROVAL_STALE"],
|
|
210
|
+
kind: "stale",
|
|
211
|
+
};
|
|
212
|
+
next.run.status = "WAITING_HUMAN";
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "CHECKPOINT": {
|
|
216
|
+
next.lastCheckpoint = {
|
|
217
|
+
seq: event.seq,
|
|
218
|
+
resumePoint: data.resumePoint,
|
|
219
|
+
workspaceStates: data.workspaceStates,
|
|
220
|
+
};
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "RUN_INTERRUPTED": {
|
|
224
|
+
next.interrupted = { seq: event.seq, cause: data.cause };
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "RUN_TERMINAL": {
|
|
228
|
+
next.terminal = { status: data.status, reason: data.reason };
|
|
229
|
+
next.run.status = data.status;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case "RUN_COMPACTED": {
|
|
233
|
+
if (!state.terminal) {
|
|
234
|
+
throw new IllegalEventError("RUN_COMPACTED requires a terminal run");
|
|
235
|
+
}
|
|
236
|
+
next.compacted = { runRecordRef: data.runRecordRef, runRecordDigest: data.runRecordDigest };
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
default:
|
|
240
|
+
// Unknown type written by a newer version: preserved in the ledger,
|
|
241
|
+
// skipped here (seq already advanced above).
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
return next;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function reduceEvents(events) {
|
|
248
|
+
let state = initialState();
|
|
249
|
+
for (const event of events) {
|
|
250
|
+
state = applyEvent(state, event);
|
|
251
|
+
}
|
|
252
|
+
return state;
|
|
253
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Risk presets per docs/v2/RFC-0003-workflow-policy.md §7: fast / standard /
|
|
2
|
+
// controlled plus the legacy-four-gates migration preset. A preset is data —
|
|
3
|
+
// human boundaries (stopAt) and a policy set — never kernel-fixed gates.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
import { PolicyError, parsePolicyDoc } from "../policy/policy.js";
|
|
10
|
+
import { parseYamlSubset } from "./yaml-subset.js";
|
|
11
|
+
|
|
12
|
+
const PRESET_DIR = fileURLToPath(new URL("../presets/risk/", import.meta.url));
|
|
13
|
+
const PRESET_FIELDS = new Set(["kind", "version", "name", "stopAt", "policies"]);
|
|
14
|
+
|
|
15
|
+
export function parseRiskPreset(doc) {
|
|
16
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
17
|
+
throw new PolicyError("risk preset must be a map");
|
|
18
|
+
}
|
|
19
|
+
for (const key of Object.keys(doc)) {
|
|
20
|
+
if (!PRESET_FIELDS.has(key)) {
|
|
21
|
+
throw new PolicyError(`unknown field "${key}" in risk preset ${doc.name ?? "(unnamed)"}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (doc.kind !== "risk-preset" || doc.version !== 1) {
|
|
25
|
+
throw new PolicyError("risk preset requires kind: risk-preset and version: 1");
|
|
26
|
+
}
|
|
27
|
+
if (typeof doc.name !== "string" || doc.name.length === 0) {
|
|
28
|
+
throw new PolicyError("risk preset needs a name");
|
|
29
|
+
}
|
|
30
|
+
const stopAt = doc.stopAt ?? [];
|
|
31
|
+
if (!Array.isArray(stopAt) || stopAt.some((step) => typeof step !== "string")) {
|
|
32
|
+
throw new PolicyError(`risk preset ${doc.name}: stopAt must be a list of step ids`);
|
|
33
|
+
}
|
|
34
|
+
const policies = (doc.policies ?? []).map((policy) => parsePolicyDoc(policy));
|
|
35
|
+
return { name: doc.name, stopAt, policies };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadRiskPreset(name) {
|
|
39
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
40
|
+
throw new PolicyError(`invalid risk preset name: ${name}`);
|
|
41
|
+
}
|
|
42
|
+
const text = readFileSync(join(PRESET_DIR, `${name}.yaml`), "utf8");
|
|
43
|
+
const preset = parseRiskPreset(parseYamlSubset(text));
|
|
44
|
+
if (preset.name !== name) {
|
|
45
|
+
throw new PolicyError(`risk preset file ${name}.yaml declares name ${preset.name}`);
|
|
46
|
+
}
|
|
47
|
+
return preset;
|
|
48
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Minimal workflow parser per docs/v2/RFC-0003-workflow-policy.md §1.
|
|
2
|
+
// The kernel is phase-agnostic: business steps come entirely from the
|
|
3
|
+
// workflow file. Movement combines the declared step order (each step chains
|
|
4
|
+
// to the next on "succeeded") with explicit transitions, which override the
|
|
5
|
+
// default edge for the same (from, on) pair. Unknown fields are rejected.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
import { parseYamlSubset } from "./yaml-subset.js";
|
|
10
|
+
|
|
11
|
+
export class WorkflowError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "WorkflowError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
|
+
const TOP_FIELDS = new Set([
|
|
20
|
+
"kind",
|
|
21
|
+
"version",
|
|
22
|
+
"name",
|
|
23
|
+
"entry",
|
|
24
|
+
"steps",
|
|
25
|
+
"transitions",
|
|
26
|
+
"terminal",
|
|
27
|
+
"policies",
|
|
28
|
+
"budgets",
|
|
29
|
+
]);
|
|
30
|
+
const STEP_FIELDS = new Set(["id", "worker", "optional", "requiredWhen", "readonly"]);
|
|
31
|
+
const TRANSITION_FIELDS = new Set(["from", "on", "to"]);
|
|
32
|
+
|
|
33
|
+
function rejectUnknownFields(object, allowed, where) {
|
|
34
|
+
for (const key of Object.keys(object)) {
|
|
35
|
+
if (!allowed.has(key)) {
|
|
36
|
+
throw new WorkflowError(`unknown field "${key}" in ${where}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function requireId(value, where) {
|
|
42
|
+
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
|
43
|
+
throw new WorkflowError(`${where} must be a lowercase id, got: ${JSON.stringify(value)}`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseWorkflow(text) {
|
|
49
|
+
const doc = parseYamlSubset(text);
|
|
50
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
51
|
+
throw new WorkflowError("workflow document must be a map");
|
|
52
|
+
}
|
|
53
|
+
rejectUnknownFields(doc, TOP_FIELDS, "workflow");
|
|
54
|
+
if (doc.kind !== "workflow") {
|
|
55
|
+
throw new WorkflowError(`kind must be "workflow", got: ${JSON.stringify(doc.kind)}`);
|
|
56
|
+
}
|
|
57
|
+
if (doc.version !== 1) {
|
|
58
|
+
throw new WorkflowError(`unsupported workflow version: ${JSON.stringify(doc.version)}`);
|
|
59
|
+
}
|
|
60
|
+
const name = requireId(doc.name, "name");
|
|
61
|
+
if (!Array.isArray(doc.steps) || doc.steps.length === 0) {
|
|
62
|
+
throw new WorkflowError("steps must be a non-empty list");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const steps = [];
|
|
66
|
+
const stepIds = new Set();
|
|
67
|
+
for (const raw of doc.steps) {
|
|
68
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
69
|
+
throw new WorkflowError("each step must be a map");
|
|
70
|
+
}
|
|
71
|
+
rejectUnknownFields(raw, STEP_FIELDS, `step ${JSON.stringify(raw.id)}`);
|
|
72
|
+
const id = requireId(raw.id, "step id");
|
|
73
|
+
if (stepIds.has(id)) {
|
|
74
|
+
throw new WorkflowError(`duplicate step id: ${id}`);
|
|
75
|
+
}
|
|
76
|
+
if (raw.worker !== undefined) {
|
|
77
|
+
requireId(raw.worker, `step ${id} worker`);
|
|
78
|
+
}
|
|
79
|
+
if (raw.optional !== undefined && typeof raw.optional !== "boolean") {
|
|
80
|
+
throw new WorkflowError(`step ${id} optional must be a boolean`);
|
|
81
|
+
}
|
|
82
|
+
if (raw.readonly !== undefined && typeof raw.readonly !== "boolean") {
|
|
83
|
+
throw new WorkflowError(`step ${id} readonly must be a boolean`);
|
|
84
|
+
}
|
|
85
|
+
stepIds.add(id);
|
|
86
|
+
steps.push({
|
|
87
|
+
id,
|
|
88
|
+
worker: raw.worker ?? null,
|
|
89
|
+
optional: raw.optional ?? false,
|
|
90
|
+
requiredWhen: raw.requiredWhen ?? null,
|
|
91
|
+
readonly: raw.readonly ?? false,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const entry = requireId(doc.entry, "entry");
|
|
96
|
+
if (!stepIds.has(entry)) {
|
|
97
|
+
throw new WorkflowError(`entry references unknown step: ${entry}`);
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(doc.terminal) || doc.terminal.length === 0) {
|
|
100
|
+
throw new WorkflowError("terminal must be a non-empty list");
|
|
101
|
+
}
|
|
102
|
+
const terminal = new Set();
|
|
103
|
+
for (const id of doc.terminal) {
|
|
104
|
+
if (!stepIds.has(id)) {
|
|
105
|
+
throw new WorkflowError(`terminal references unknown step: ${id}`);
|
|
106
|
+
}
|
|
107
|
+
terminal.add(id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const edges = new Map();
|
|
111
|
+
const explicit = doc.transitions ?? [];
|
|
112
|
+
if (!Array.isArray(explicit)) {
|
|
113
|
+
throw new WorkflowError("transitions must be a list");
|
|
114
|
+
}
|
|
115
|
+
for (const raw of explicit) {
|
|
116
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
117
|
+
throw new WorkflowError("each transition must be a map");
|
|
118
|
+
}
|
|
119
|
+
rejectUnknownFields(raw, TRANSITION_FIELDS, "transition");
|
|
120
|
+
const from = requireId(raw.from, "transition from");
|
|
121
|
+
const to = requireId(raw.to, "transition to");
|
|
122
|
+
if (!stepIds.has(from) || !stepIds.has(to)) {
|
|
123
|
+
throw new WorkflowError(`transition references unknown step: ${from} -> ${to}`);
|
|
124
|
+
}
|
|
125
|
+
if (typeof raw.on !== "string" || raw.on.length === 0) {
|
|
126
|
+
throw new WorkflowError(`transition ${from} -> ${to} needs a non-empty "on"`);
|
|
127
|
+
}
|
|
128
|
+
const key = `${from}|${raw.on}`;
|
|
129
|
+
if (edges.has(key)) {
|
|
130
|
+
throw new WorkflowError(`duplicate transition for (${from}, ${raw.on})`);
|
|
131
|
+
}
|
|
132
|
+
edges.set(key, to);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const [index, step] of steps.entries()) {
|
|
136
|
+
const key = `${step.id}|succeeded`;
|
|
137
|
+
if (terminal.has(step.id) || edges.has(key)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const next = steps[index + 1];
|
|
141
|
+
if (next) {
|
|
142
|
+
edges.set(key, next.id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const outgoing = new Map();
|
|
147
|
+
for (const [key, to] of edges) {
|
|
148
|
+
const from = key.split("|")[0];
|
|
149
|
+
if (!outgoing.has(from)) {
|
|
150
|
+
outgoing.set(from, []);
|
|
151
|
+
}
|
|
152
|
+
outgoing.get(from).push(to);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const reachable = new Set([entry]);
|
|
156
|
+
const queue = [entry];
|
|
157
|
+
while (queue.length > 0) {
|
|
158
|
+
const current = queue.pop();
|
|
159
|
+
for (const to of outgoing.get(current) ?? []) {
|
|
160
|
+
if (!reachable.has(to)) {
|
|
161
|
+
reachable.add(to);
|
|
162
|
+
queue.push(to);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const reachesTerminal = new Set(terminal);
|
|
167
|
+
let grew = true;
|
|
168
|
+
while (grew) {
|
|
169
|
+
grew = false;
|
|
170
|
+
for (const [from, targets] of outgoing) {
|
|
171
|
+
if (!reachesTerminal.has(from) && targets.some((to) => reachesTerminal.has(to))) {
|
|
172
|
+
reachesTerminal.add(from);
|
|
173
|
+
grew = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const id of reachable) {
|
|
178
|
+
if (!reachesTerminal.has(id)) {
|
|
179
|
+
throw new WorkflowError(`step "${id}" has no path to a terminal step`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
name,
|
|
185
|
+
entry,
|
|
186
|
+
steps,
|
|
187
|
+
stepIds,
|
|
188
|
+
terminal,
|
|
189
|
+
edges,
|
|
190
|
+
policies: doc.policies ?? [],
|
|
191
|
+
budgets: doc.budgets ?? {},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function loadWorkflow(filePath) {
|
|
196
|
+
return parseWorkflow(readFileSync(filePath, "utf8"));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function nextStep(workflow, from, outcome) {
|
|
200
|
+
return workflow.edges.get(`${from}|${outcome}`) ?? null;
|
|
201
|
+
}
|