@kylecheng3146/agent-ops 0.5.3 → 0.6.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/README.md +48 -0
- package/dist/packages/cli/src/args.js +165 -2
- package/dist/packages/cli/src/bin.js +64 -17
- package/dist/packages/cli/src/cli.js +33 -6
- package/dist/packages/cli/src/commands/advance.js +23 -3
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +100 -9
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +61 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +288 -10
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +71 -5
- package/dist/runtime/src/parallel/service.js +41 -1
- package/dist/runtime/src/review/attestation.js +36 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +67 -7
- package/dist/runtime/src/task/completion.js +14 -2
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/service.js +316 -13
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/spec/acceptance-and-evidence.md +48 -0
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +265 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalJson } from "../config/hash.js";
|
|
5
|
+
import { sha256 } from "../fs/hash.js";
|
|
6
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
7
|
+
import { ensurePrivateDirectory, readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
8
|
+
/**
|
|
9
|
+
* A policy transition is deliberately a small journal rather than another
|
|
10
|
+
* run store. The policy module owns the policy artifact; this module only
|
|
11
|
+
* records the identities that must be fenced and the synchronization proof
|
|
12
|
+
* that the coordinator supplies.
|
|
13
|
+
*/
|
|
14
|
+
export const POLICY_TRANSITION_STAGES = [
|
|
15
|
+
"prepared",
|
|
16
|
+
"fenced",
|
|
17
|
+
"bound",
|
|
18
|
+
"synchronized",
|
|
19
|
+
"complete"
|
|
20
|
+
];
|
|
21
|
+
const HASH = /^[a-f0-9]{64}$/u;
|
|
22
|
+
const ID = /^[A-Za-z0-9._-]{1,128}$/u;
|
|
23
|
+
const MAX_ARTIFACT_BYTES = 512 * 1024;
|
|
24
|
+
const MAX_ITEMS = 512;
|
|
25
|
+
const ACTIVE_WORKER_STATUSES = new Set([
|
|
26
|
+
"assigned",
|
|
27
|
+
"starting",
|
|
28
|
+
"running",
|
|
29
|
+
"idle",
|
|
30
|
+
"handing-off"
|
|
31
|
+
]);
|
|
32
|
+
const FENCED_WORKER_STATUSES = new Set(["fenced", "stopped", "delivered", "blocked"]);
|
|
33
|
+
function fail(code, message) {
|
|
34
|
+
throw new AgentOpsError(code, message);
|
|
35
|
+
}
|
|
36
|
+
function plain(value) {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function boundedText(value, label, max = 4096) {
|
|
40
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max || value.includes("\0")) {
|
|
41
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", `${label} must be a bounded non-empty string.`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function timestamp(value, label) {
|
|
46
|
+
const text = boundedText(value, label, 64);
|
|
47
|
+
if (!Number.isFinite(Date.parse(text)))
|
|
48
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", `${label} must be an ISO timestamp.`);
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
function digest(value, label) {
|
|
52
|
+
if (typeof value !== "string" || !HASH.test(value)) {
|
|
53
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", `${label} must be a SHA-256 digest.`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function transitionId(value) {
|
|
58
|
+
const id = boundedText(value, "transitionId", 128);
|
|
59
|
+
if (!ID.test(id))
|
|
60
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", "Transition ID contains an unsafe path character.");
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
function nowIso(value) {
|
|
64
|
+
return timestamp(value ?? new Date().toISOString(), "transition timestamp");
|
|
65
|
+
}
|
|
66
|
+
function stateWithPolicy(state) {
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
69
|
+
function policyPath(runId, artifactDigest) {
|
|
70
|
+
return `agent-ops/runs/${runId}/policies/${artifactDigest}.json`;
|
|
71
|
+
}
|
|
72
|
+
function transitionPath(runId, id) {
|
|
73
|
+
return join("agent-ops", "runs", runId, "policy-transitions", `${id}.json`);
|
|
74
|
+
}
|
|
75
|
+
function transitionAbsolutePath(state, id) {
|
|
76
|
+
return join(state.commonDir, transitionPath(state.runId, id));
|
|
77
|
+
}
|
|
78
|
+
function transitionDirectory(state) {
|
|
79
|
+
return join(state.commonDir, "agent-ops", "runs", state.runId, "policy-transitions");
|
|
80
|
+
}
|
|
81
|
+
function sameJson(left, right) {
|
|
82
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
83
|
+
}
|
|
84
|
+
function validArtifactPath(path) {
|
|
85
|
+
return typeof path === "string" && path.length > 0 && path.length <= 4096 && !path.includes("\0") &&
|
|
86
|
+
!path.startsWith("/") && !path.includes("\\") && !path.split("/").some(segment => segment === "" || segment === "." || segment === "..");
|
|
87
|
+
}
|
|
88
|
+
function artifactReference(value, label) {
|
|
89
|
+
if (!plain(value) || Object.keys(value).sort().join(",") !== "artifactDigest,artifactPath,configHash,expiresAt,runtimeHash" ||
|
|
90
|
+
!HASH.test(String(value.artifactDigest)) || !HASH.test(String(value.configHash)) || !HASH.test(String(value.runtimeHash)) ||
|
|
91
|
+
!validArtifactPath(value.artifactPath) || typeof value.expiresAt !== "string" ||
|
|
92
|
+
!Number.isFinite(Date.parse(value.expiresAt))) {
|
|
93
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", `${label} is not a valid policy artifact reference.`);
|
|
94
|
+
}
|
|
95
|
+
const artifactDigest = value.artifactDigest;
|
|
96
|
+
const artifactPath = value.artifactPath;
|
|
97
|
+
const configHash = value.configHash;
|
|
98
|
+
const runtimeHash = value.runtimeHash;
|
|
99
|
+
const expiresAt = value.expiresAt;
|
|
100
|
+
return {
|
|
101
|
+
artifactDigest,
|
|
102
|
+
artifactPath,
|
|
103
|
+
configHash,
|
|
104
|
+
runtimeHash,
|
|
105
|
+
expiresAt
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function renewalCore(input) {
|
|
109
|
+
return {
|
|
110
|
+
previousPolicyArtifact: input.previousPolicyArtifact,
|
|
111
|
+
renewedPolicyArtifact: input.renewedPolicyArtifact,
|
|
112
|
+
at: input.at,
|
|
113
|
+
previousJournalDigest: input.previousJournalDigest,
|
|
114
|
+
previousRenewalDigest: input.previousRenewalDigest
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function sealRenewal(input) {
|
|
118
|
+
const core = renewalCore(input);
|
|
119
|
+
return { ...core, renewalDigest: sha256(canonicalJson(core)) };
|
|
120
|
+
}
|
|
121
|
+
function policyArtifactPath(runId, artifactDigest) {
|
|
122
|
+
return policyPath(runId, artifactDigest);
|
|
123
|
+
}
|
|
124
|
+
function policyArtifactRefFromState(state) {
|
|
125
|
+
const binding = stateWithPolicy(state).policyBinding;
|
|
126
|
+
if (binding === undefined || binding === null)
|
|
127
|
+
return null;
|
|
128
|
+
if (!plain(binding) ||
|
|
129
|
+
typeof binding.artifactDigest !== "string" || typeof binding.configHash !== "string" ||
|
|
130
|
+
typeof binding.runtimeHash !== "string" || typeof binding.expiresAt !== "string") {
|
|
131
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", "Run policy binding is malformed.");
|
|
132
|
+
}
|
|
133
|
+
return artifactReference({
|
|
134
|
+
artifactDigest: binding.artifactDigest,
|
|
135
|
+
artifactPath: policyPath(state.runId, binding.artifactDigest),
|
|
136
|
+
configHash: binding.configHash,
|
|
137
|
+
runtimeHash: binding.runtimeHash,
|
|
138
|
+
expiresAt: binding.expiresAt
|
|
139
|
+
}, "run policy binding");
|
|
140
|
+
}
|
|
141
|
+
/** Return the non-secret policy identity currently bound to a run. */
|
|
142
|
+
export function currentPolicyArtifact(state) {
|
|
143
|
+
return policyArtifactRefFromState(state);
|
|
144
|
+
}
|
|
145
|
+
function artifactMatches(left, right) {
|
|
146
|
+
return sameJson(left, right);
|
|
147
|
+
}
|
|
148
|
+
function cloneStopIntent(intent) {
|
|
149
|
+
return intent === null ? null : {
|
|
150
|
+
runId: intent.runId,
|
|
151
|
+
workerId: intent.workerId,
|
|
152
|
+
generation: intent.generation,
|
|
153
|
+
nativeSessionId: intent.nativeSessionId,
|
|
154
|
+
reason: intent.reason,
|
|
155
|
+
deliveryDigest: intent.deliveryDigest,
|
|
156
|
+
contractDigest: intent.contractDigest,
|
|
157
|
+
sourceCommit: intent.sourceCommit,
|
|
158
|
+
expiresAt: intent.expiresAt,
|
|
159
|
+
confirmedDeadAt: intent.confirmedDeadAt
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function workerSnapshot(worker) {
|
|
163
|
+
return {
|
|
164
|
+
workerId: worker.workerId,
|
|
165
|
+
taskId: worker.taskId,
|
|
166
|
+
generation: worker.generation,
|
|
167
|
+
status: worker.status,
|
|
168
|
+
nativeSessionId: worker.nativeSessionId,
|
|
169
|
+
nativeJobId: worker.nativeJobId,
|
|
170
|
+
processId: worker.processId,
|
|
171
|
+
processIdentity: worker.processIdentity,
|
|
172
|
+
deliveryDigest: worker.stopIntent?.deliveryDigest ?? null,
|
|
173
|
+
sourceCommit: worker.stopIntent?.sourceCommit ?? null,
|
|
174
|
+
stopIntent: cloneStopIntent(worker.stopIntent)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function taskSnapshot(task) {
|
|
178
|
+
return {
|
|
179
|
+
taskId: task.taskId,
|
|
180
|
+
status: task.status,
|
|
181
|
+
workerId: task.workerId,
|
|
182
|
+
deliveryDigest: task.deliveryDigest,
|
|
183
|
+
sourceCommit: task.sourceCommit,
|
|
184
|
+
planDigest: task.planDigest ?? null
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function snapshotWorkers(state) {
|
|
188
|
+
return [...state.workers].sort((left, right) => left.workerId.localeCompare(right.workerId)).map(workerSnapshot);
|
|
189
|
+
}
|
|
190
|
+
function snapshotTasks(state) {
|
|
191
|
+
return [...state.tasks].sort((left, right) => left.taskId.localeCompare(right.taskId)).map(taskSnapshot);
|
|
192
|
+
}
|
|
193
|
+
function resumeIds(state) {
|
|
194
|
+
return [...state.workers]
|
|
195
|
+
.filter(worker => ACTIVE_WORKER_STATUSES.has(worker.status))
|
|
196
|
+
.sort((left, right) => left.workerId.localeCompare(right.workerId))
|
|
197
|
+
.map(worker => ({
|
|
198
|
+
workerId: worker.workerId,
|
|
199
|
+
generation: worker.generation,
|
|
200
|
+
nativeSessionId: worker.nativeSessionId,
|
|
201
|
+
nativeJobId: worker.nativeJobId
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
function deliveredMarkers(state) {
|
|
205
|
+
return [...state.workers]
|
|
206
|
+
.filter(worker => worker.status === "delivered" || state.tasks.some(task => task.workerId === worker.workerId && task.status === "delivered"))
|
|
207
|
+
.sort((left, right) => left.workerId.localeCompare(right.workerId))
|
|
208
|
+
.map(worker => ({
|
|
209
|
+
workerId: worker.workerId,
|
|
210
|
+
taskId: worker.taskId,
|
|
211
|
+
generation: worker.generation,
|
|
212
|
+
deliveryDigest: state.tasks.find(task => task.taskId === worker.taskId)?.deliveryDigest ?? worker.stopIntent?.deliveryDigest ?? null,
|
|
213
|
+
sourceCommit: state.tasks.find(task => task.taskId === worker.taskId)?.sourceCommit ?? worker.stopIntent?.sourceCommit ?? null
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
function stageCore(value) {
|
|
217
|
+
const normalized = {
|
|
218
|
+
...value,
|
|
219
|
+
previousStageDigest: value.previousStageDigest ?? null
|
|
220
|
+
};
|
|
221
|
+
const stageDigest = sha256(canonicalJson(normalized));
|
|
222
|
+
return { ...normalized, stageDigest };
|
|
223
|
+
}
|
|
224
|
+
function sealJournal(journal) {
|
|
225
|
+
return { ...journal, artifactDigest: sha256(canonicalJson(journal)) };
|
|
226
|
+
}
|
|
227
|
+
function stageCode(stage) {
|
|
228
|
+
return `RUN_POLICY_TRANSITION_${stage.toUpperCase()}`;
|
|
229
|
+
}
|
|
230
|
+
function eventDetail(journal) {
|
|
231
|
+
const latest = journal.stages[journal.stages.length - 1];
|
|
232
|
+
if (latest === undefined)
|
|
233
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", "Transition journal has no stage.");
|
|
234
|
+
return canonicalJson({
|
|
235
|
+
transitionId: journal.transitionId,
|
|
236
|
+
stage: latest.stage,
|
|
237
|
+
artifactPath: transitionPath(journal.runId, journal.transitionId),
|
|
238
|
+
artifactDigest: journal.artifactDigest,
|
|
239
|
+
stageDigest: latest.stageDigest
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function transitionEvent(journal) {
|
|
243
|
+
const latest = journal.stages[journal.stages.length - 1];
|
|
244
|
+
if (latest === undefined)
|
|
245
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", "Transition journal has no stage.");
|
|
246
|
+
return {
|
|
247
|
+
id: randomUUID(),
|
|
248
|
+
at: latest.at,
|
|
249
|
+
type: "control",
|
|
250
|
+
code: stageCode(latest.stage),
|
|
251
|
+
workerId: null,
|
|
252
|
+
taskId: null,
|
|
253
|
+
detail: eventDetail(journal)
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function hasStageReference(state, journal) {
|
|
257
|
+
const latest = journal.stages[journal.stages.length - 1];
|
|
258
|
+
return latest !== undefined && state.events.some(event => {
|
|
259
|
+
if (event.code !== stageCode(latest.stage) || event.detail === null)
|
|
260
|
+
return false;
|
|
261
|
+
try {
|
|
262
|
+
const detail = JSON.parse(event.detail);
|
|
263
|
+
return detail.transitionId === journal.transitionId && detail.stageDigest === latest.stageDigest &&
|
|
264
|
+
detail.artifactDigest === journal.artifactDigest;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function withTransitionEvent(state, journal) {
|
|
272
|
+
if (hasStageReference(state, journal))
|
|
273
|
+
return state;
|
|
274
|
+
return {
|
|
275
|
+
...state,
|
|
276
|
+
events: [...state.events, transitionEvent(journal)].slice(-2_000)
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function renewalEvent(journal) {
|
|
280
|
+
const renewal = journal.renewals.at(-1);
|
|
281
|
+
if (renewal === undefined)
|
|
282
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", "Policy renewal event has no renewal record.");
|
|
283
|
+
return {
|
|
284
|
+
id: randomUUID(),
|
|
285
|
+
at: renewal.at,
|
|
286
|
+
type: "control",
|
|
287
|
+
code: "RUN_POLICY_TRANSITION_RENEWED",
|
|
288
|
+
workerId: null,
|
|
289
|
+
taskId: null,
|
|
290
|
+
detail: canonicalJson({
|
|
291
|
+
transitionId: journal.transitionId,
|
|
292
|
+
previousJournalDigest: renewal.previousJournalDigest,
|
|
293
|
+
previousPolicyArtifact: renewal.previousPolicyArtifact.artifactDigest,
|
|
294
|
+
renewedPolicyArtifact: renewal.renewedPolicyArtifact.artifactDigest,
|
|
295
|
+
renewalDigest: renewal.renewalDigest,
|
|
296
|
+
artifactDigest: journal.artifactDigest
|
|
297
|
+
})
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function hasRenewalReference(state, journal) {
|
|
301
|
+
const renewal = journal.renewals.at(-1);
|
|
302
|
+
if (renewal === undefined)
|
|
303
|
+
return false;
|
|
304
|
+
return state.events.some(event => {
|
|
305
|
+
if (event.code !== "RUN_POLICY_TRANSITION_RENEWED" || event.detail === null)
|
|
306
|
+
return false;
|
|
307
|
+
try {
|
|
308
|
+
const detail = JSON.parse(event.detail);
|
|
309
|
+
return detail.transitionId === journal.transitionId && detail.renewalDigest === renewal.renewalDigest &&
|
|
310
|
+
detail.artifactDigest === journal.artifactDigest;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function withRenewalEvent(state, journal) {
|
|
318
|
+
if (hasRenewalReference(state, journal))
|
|
319
|
+
return state;
|
|
320
|
+
return { ...state, events: [...state.events, renewalEvent(journal)].slice(-2_000) };
|
|
321
|
+
}
|
|
322
|
+
function stageIndex(stage) {
|
|
323
|
+
return POLICY_TRANSITION_STAGES.indexOf(stage);
|
|
324
|
+
}
|
|
325
|
+
function stageAt(journal) {
|
|
326
|
+
const stage = journal.stages[journal.stages.length - 1]?.stage;
|
|
327
|
+
if (stage === undefined)
|
|
328
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", "Transition journal has no current stage.");
|
|
329
|
+
return stage;
|
|
330
|
+
}
|
|
331
|
+
function validateTaskIds(value, label) {
|
|
332
|
+
if (!Array.isArray(value) || value.length > MAX_ITEMS || value.some(item => typeof item !== "string" || !ID.test(item))) {
|
|
333
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", `${label} contains invalid task IDs.`);
|
|
334
|
+
}
|
|
335
|
+
return [...value].sort();
|
|
336
|
+
}
|
|
337
|
+
function validateStage(value, label) {
|
|
338
|
+
if (typeof value !== "string" || !POLICY_TRANSITION_STAGES.includes(value)) {
|
|
339
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", `${label} is not a supported transition stage.`);
|
|
340
|
+
}
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
function validateStageRecord(value, previous) {
|
|
344
|
+
if (!plain(value))
|
|
345
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition stage is not an object.");
|
|
346
|
+
const stage = validateStage(value.stage, "stage");
|
|
347
|
+
const at = timestamp(value.at, "stage.at");
|
|
348
|
+
const previousStageDigest = value.previousStageDigest === null ? null : digest(value.previousStageDigest, "stage.previousStageDigest");
|
|
349
|
+
const contractRevision = value.contractRevision;
|
|
350
|
+
if (contractRevision !== undefined && (!Number.isSafeInteger(contractRevision) || contractRevision < 0)) {
|
|
351
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Stage contract revision is invalid.");
|
|
352
|
+
}
|
|
353
|
+
const core = {
|
|
354
|
+
stage,
|
|
355
|
+
at,
|
|
356
|
+
previousStageDigest,
|
|
357
|
+
...(value.newPolicyArtifact === undefined ? {} : { newPolicyArtifact: artifactReference(value.newPolicyArtifact, "stage.newPolicyArtifact") }),
|
|
358
|
+
...(value.contractHash === undefined ? {} : { contractHash: value.contractHash === null ? null : digest(value.contractHash, "stage.contractHash") }),
|
|
359
|
+
...(contractRevision === undefined ? {} : { contractRevision: contractRevision }),
|
|
360
|
+
...(value.taskIds === undefined ? {} : { taskIds: validateTaskIds(value.taskIds, "stage.taskIds") }),
|
|
361
|
+
...(value.synchronizationDigest === undefined ? {} : { synchronizationDigest: digest(value.synchronizationDigest, "stage.synchronizationDigest") })
|
|
362
|
+
};
|
|
363
|
+
const stageDigest = digest(value.stageDigest, "stage.stageDigest");
|
|
364
|
+
if (stageDigest !== sha256(canonicalJson(core)))
|
|
365
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition stage digest does not match its contents.");
|
|
366
|
+
if ((previous?.stageDigest ?? null) !== previousStageDigest)
|
|
367
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition stage chain is broken.");
|
|
368
|
+
return { ...core, previousStageDigest, stageDigest };
|
|
369
|
+
}
|
|
370
|
+
function validateSnapshotArray(value, label) {
|
|
371
|
+
if (!Array.isArray(value) || value.length > MAX_ITEMS || value.some(item => !plain(item))) {
|
|
372
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", `${label} is invalid.`);
|
|
373
|
+
}
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
function nullableSnapshotText(value, label, max = 4096) {
|
|
377
|
+
return value === null ? null : boundedText(value, label, max);
|
|
378
|
+
}
|
|
379
|
+
function validateTaskSnapshots(value) {
|
|
380
|
+
return validateSnapshotArray(value, "journal.tasks").map(item => {
|
|
381
|
+
if (!ID.test(String(item.taskId)) || typeof item.status !== "string" || item.status.length > 128 ||
|
|
382
|
+
(item.workerId !== null && !ID.test(String(item.workerId))) ||
|
|
383
|
+
(item.planDigest !== null && !HASH.test(String(item.planDigest)))) {
|
|
384
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "A task snapshot is invalid.");
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
taskId: item.taskId,
|
|
388
|
+
status: item.status,
|
|
389
|
+
workerId: item.workerId,
|
|
390
|
+
deliveryDigest: nullableSnapshotText(item.deliveryDigest, "task.deliveryDigest", 128),
|
|
391
|
+
sourceCommit: nullableSnapshotText(item.sourceCommit, "task.sourceCommit", 128),
|
|
392
|
+
planDigest: item.planDigest
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
function validateWorkerSnapshots(value) {
|
|
397
|
+
return validateSnapshotArray(value, "journal.workers").map(item => {
|
|
398
|
+
if (!ID.test(String(item.workerId)) || !ID.test(String(item.taskId)) || !Number.isSafeInteger(item.generation) ||
|
|
399
|
+
item.generation < 1 || typeof item.status !== "string" || item.status.length > 128 ||
|
|
400
|
+
(item.processId !== null && (!Number.isSafeInteger(item.processId) || item.processId < 1))) {
|
|
401
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "A worker snapshot is invalid.");
|
|
402
|
+
}
|
|
403
|
+
let stopIntent = null;
|
|
404
|
+
if (item.stopIntent !== null) {
|
|
405
|
+
if (!plain(item.stopIntent) || !ID.test(String(item.stopIntent.workerId)) || !Number.isSafeInteger(item.stopIntent.generation) ||
|
|
406
|
+
item.stopIntent.generation < 1 || typeof item.stopIntent.runId !== "string" ||
|
|
407
|
+
!["handoff", "stop", "budget", "no-progress", "crash"].includes(String(item.stopIntent.reason))) {
|
|
408
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "A worker stop intent is invalid.");
|
|
409
|
+
}
|
|
410
|
+
stopIntent = item.stopIntent;
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
workerId: item.workerId,
|
|
414
|
+
taskId: item.taskId,
|
|
415
|
+
generation: item.generation,
|
|
416
|
+
status: item.status,
|
|
417
|
+
nativeSessionId: nullableSnapshotText(item.nativeSessionId, "worker.nativeSessionId"),
|
|
418
|
+
nativeJobId: nullableSnapshotText(item.nativeJobId, "worker.nativeJobId"),
|
|
419
|
+
processId: item.processId,
|
|
420
|
+
processIdentity: nullableSnapshotText(item.processIdentity, "worker.processIdentity"),
|
|
421
|
+
deliveryDigest: nullableSnapshotText(item.deliveryDigest, "worker.deliveryDigest", 128),
|
|
422
|
+
sourceCommit: nullableSnapshotText(item.sourceCommit, "worker.sourceCommit", 128),
|
|
423
|
+
stopIntent
|
|
424
|
+
};
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function validateWorkerGenerations(value) {
|
|
428
|
+
return validateSnapshotArray(value, "journal.workerGenerations").map(item => {
|
|
429
|
+
if (!ID.test(String(item.workerId)) || !Number.isSafeInteger(item.generation) || item.generation < 1) {
|
|
430
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "A worker generation snapshot is invalid.");
|
|
431
|
+
}
|
|
432
|
+
return { workerId: item.workerId, generation: item.generation };
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
function validateResumeIds(value) {
|
|
436
|
+
return validateSnapshotArray(value, "journal.resumeIds").map(item => {
|
|
437
|
+
if (!ID.test(String(item.workerId)) || !Number.isSafeInteger(item.generation) || item.generation < 1) {
|
|
438
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "A resume identity is invalid.");
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
workerId: item.workerId,
|
|
442
|
+
generation: item.generation,
|
|
443
|
+
nativeSessionId: nullableSnapshotText(item.nativeSessionId, "resume.nativeSessionId"),
|
|
444
|
+
nativeJobId: nullableSnapshotText(item.nativeJobId, "resume.nativeJobId")
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
function validateDeliveredMarkers(value) {
|
|
449
|
+
return validateSnapshotArray(value, "journal.deliveredMarkers").map(item => {
|
|
450
|
+
if (!ID.test(String(item.workerId)) || !ID.test(String(item.taskId)) || !Number.isSafeInteger(item.generation) || item.generation < 1) {
|
|
451
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "A delivered marker is invalid.");
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
workerId: item.workerId,
|
|
455
|
+
taskId: item.taskId,
|
|
456
|
+
generation: item.generation,
|
|
457
|
+
deliveryDigest: nullableSnapshotText(item.deliveryDigest, "delivery.deliveryDigest", 128),
|
|
458
|
+
sourceCommit: nullableSnapshotText(item.sourceCommit, "delivery.sourceCommit", 128)
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function validateRenewals(value) {
|
|
463
|
+
if (value === undefined)
|
|
464
|
+
return [];
|
|
465
|
+
if (!Array.isArray(value) || value.length > MAX_ITEMS) {
|
|
466
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Journal policy renewals are invalid.");
|
|
467
|
+
}
|
|
468
|
+
const renewals = value.map((raw, index) => {
|
|
469
|
+
if (!plain(raw) || Object.keys(raw).sort().join(",") !==
|
|
470
|
+
["at", "previousJournalDigest", "previousPolicyArtifact", "previousRenewalDigest", "renewalDigest", "renewedPolicyArtifact"].sort().join(",")) {
|
|
471
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", `Policy renewal ${index} is invalid.`);
|
|
472
|
+
}
|
|
473
|
+
const previousPolicyArtifact = artifactReference(raw.previousPolicyArtifact, `renewal[${index}].previousPolicyArtifact`);
|
|
474
|
+
const renewedPolicyArtifact = artifactReference(raw.renewedPolicyArtifact, `renewal[${index}].renewedPolicyArtifact`);
|
|
475
|
+
const at = timestamp(raw.at, `renewal[${index}].at`);
|
|
476
|
+
const previousJournalDigest = digest(raw.previousJournalDigest, `renewal[${index}].previousJournalDigest`);
|
|
477
|
+
const previousRenewalDigest = raw.previousRenewalDigest === null
|
|
478
|
+
? null
|
|
479
|
+
: digest(raw.previousRenewalDigest, `renewal[${index}].previousRenewalDigest`);
|
|
480
|
+
const renewalDigest = digest(raw.renewalDigest, `renewal[${index}].renewalDigest`);
|
|
481
|
+
const core = renewalCore({ previousPolicyArtifact, renewedPolicyArtifact, at, previousJournalDigest, previousRenewalDigest });
|
|
482
|
+
if (renewalDigest !== sha256(canonicalJson(core))) {
|
|
483
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", `Policy renewal ${index} digest does not match its contents.`);
|
|
484
|
+
}
|
|
485
|
+
return { ...core, renewalDigest };
|
|
486
|
+
});
|
|
487
|
+
let previousArtifact;
|
|
488
|
+
let previousRenewalDigest = null;
|
|
489
|
+
for (const renewal of renewals) {
|
|
490
|
+
if (previousArtifact !== undefined && !artifactMatches(renewal.previousPolicyArtifact, previousArtifact)) {
|
|
491
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Policy renewal artifact chain is broken.");
|
|
492
|
+
}
|
|
493
|
+
if (renewal.previousRenewalDigest !== previousRenewalDigest) {
|
|
494
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Policy renewal digest chain is broken.");
|
|
495
|
+
}
|
|
496
|
+
previousArtifact = renewal.renewedPolicyArtifact;
|
|
497
|
+
previousRenewalDigest = renewal.renewalDigest;
|
|
498
|
+
}
|
|
499
|
+
return renewals;
|
|
500
|
+
}
|
|
501
|
+
function validateJournal(value, expectedRunId, expectedTransitionId) {
|
|
502
|
+
if (!plain(value) || value.schemaVersion !== 1 || value.runId !== expectedRunId ||
|
|
503
|
+
(expectedTransitionId !== undefined && value.transitionId !== expectedTransitionId)) {
|
|
504
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition journal identity is invalid.");
|
|
505
|
+
}
|
|
506
|
+
const id = transitionId(value.transitionId);
|
|
507
|
+
const createdAt = timestamp(value.createdAt, "journal.createdAt");
|
|
508
|
+
if (!Number.isSafeInteger(value.baseRevision) || value.baseRevision < 0) {
|
|
509
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition journal base revision is invalid.");
|
|
510
|
+
}
|
|
511
|
+
const oldPolicyArtifact = value.oldPolicyArtifact === null ? null : artifactReference(value.oldPolicyArtifact, "journal.oldPolicyArtifact");
|
|
512
|
+
const newPolicyArtifact = value.newPolicyArtifact === null ? null : artifactReference(value.newPolicyArtifact, "journal.newPolicyArtifact");
|
|
513
|
+
const oldContractHash = value.oldContractHash === null ? null : digest(value.oldContractHash, "journal.oldContractHash");
|
|
514
|
+
if (!Number.isSafeInteger(value.oldContractRevision) || value.oldContractRevision < 0) {
|
|
515
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition journal contract revision is invalid.");
|
|
516
|
+
}
|
|
517
|
+
const taskIds = validateTaskIds(value.taskIds, "journal.taskIds");
|
|
518
|
+
const tasks = validateTaskSnapshots(value.tasks);
|
|
519
|
+
const workers = validateWorkerSnapshots(value.workers);
|
|
520
|
+
const workerGenerations = validateWorkerGenerations(value.workerGenerations);
|
|
521
|
+
const resumeIds = validateResumeIds(value.resumeIds);
|
|
522
|
+
const deliveredMarkers = validateDeliveredMarkers(value.deliveredMarkers);
|
|
523
|
+
const renewals = validateRenewals(value.renewals);
|
|
524
|
+
if (!sameJson(tasks.map(task => task.taskId).sort(), taskIds) ||
|
|
525
|
+
!sameJson(workerGenerations, workers.map(worker => ({ workerId: worker.workerId, generation: worker.generation })).sort((left, right) => left.workerId.localeCompare(right.workerId)))) {
|
|
526
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition snapshot indexes do not match their records.");
|
|
527
|
+
}
|
|
528
|
+
if (!Array.isArray(value.stages) || value.stages.length < 1 || value.stages.length > POLICY_TRANSITION_STAGES.length) {
|
|
529
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition journal stages are invalid.");
|
|
530
|
+
}
|
|
531
|
+
const stages = [];
|
|
532
|
+
for (const [index, raw] of value.stages.entries()) {
|
|
533
|
+
const previous = stages[index - 1] ?? null;
|
|
534
|
+
const stage = validateStageRecord(raw, previous);
|
|
535
|
+
if (stage.stage !== POLICY_TRANSITION_STAGES[index]) {
|
|
536
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition stages are not monotonic.");
|
|
537
|
+
}
|
|
538
|
+
stages.push(stage);
|
|
539
|
+
}
|
|
540
|
+
if (stages[0]?.stage !== "prepared")
|
|
541
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition must start at prepared.");
|
|
542
|
+
const boundStage = stages.find(stage => stage.stage === "bound");
|
|
543
|
+
const synchronizedStage = stages.find(stage => stage.stage === "synchronized");
|
|
544
|
+
if (boundStage !== undefined && (boundStage.newPolicyArtifact === undefined || newPolicyArtifact === null)) {
|
|
545
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Bound transition is missing its new policy artifact.");
|
|
546
|
+
}
|
|
547
|
+
if (boundStage !== undefined) {
|
|
548
|
+
const boundArtifact = boundStage.newPolicyArtifact;
|
|
549
|
+
if (renewals.length === 0 && newPolicyArtifact !== null && !artifactMatches(newPolicyArtifact, boundArtifact)) {
|
|
550
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "New policy artifact does not match the bound stage.");
|
|
551
|
+
}
|
|
552
|
+
if (renewals.length > 0) {
|
|
553
|
+
if (!artifactMatches(renewals[0].previousPolicyArtifact, boundArtifact) ||
|
|
554
|
+
newPolicyArtifact === null || !artifactMatches(newPolicyArtifact, renewals.at(-1).renewedPolicyArtifact)) {
|
|
555
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Policy renewal history does not match the bound artifact.");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
else if (renewals.length > 0) {
|
|
560
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Policy renewal history requires a bound transition.");
|
|
561
|
+
}
|
|
562
|
+
if (synchronizedStage !== undefined &&
|
|
563
|
+
(synchronizedStage.contractHash === undefined || synchronizedStage.contractRevision === undefined ||
|
|
564
|
+
synchronizedStage.taskIds === undefined || synchronizedStage.synchronizationDigest === undefined)) {
|
|
565
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Synchronized stage is missing its contract/task proof.");
|
|
566
|
+
}
|
|
567
|
+
const withoutDigest = { ...value };
|
|
568
|
+
delete withoutDigest.artifactDigest;
|
|
569
|
+
const artifactDigest = digest(value.artifactDigest, "journal.artifactDigest");
|
|
570
|
+
if (artifactDigest !== sha256(canonicalJson(withoutDigest))) {
|
|
571
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition journal digest does not match its contents.");
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
schemaVersion: 1,
|
|
575
|
+
runId: expectedRunId,
|
|
576
|
+
transitionId: id,
|
|
577
|
+
createdAt,
|
|
578
|
+
baseRevision: value.baseRevision,
|
|
579
|
+
oldPolicyArtifact,
|
|
580
|
+
newPolicyArtifact,
|
|
581
|
+
oldContractHash,
|
|
582
|
+
oldContractRevision: value.oldContractRevision,
|
|
583
|
+
taskIds,
|
|
584
|
+
tasks: tasks,
|
|
585
|
+
workerGenerations: workerGenerations,
|
|
586
|
+
workers: workers,
|
|
587
|
+
resumeIds: resumeIds,
|
|
588
|
+
deliveredMarkers: deliveredMarkers,
|
|
589
|
+
stages,
|
|
590
|
+
renewals: renewals,
|
|
591
|
+
artifactDigest
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
async function readJournal(state, id) {
|
|
595
|
+
const source = await readPrivateFile(transitionAbsolutePath(state, id), state.commonDir);
|
|
596
|
+
if (source === null)
|
|
597
|
+
return null;
|
|
598
|
+
if (Buffer.byteLength(source, "utf8") > MAX_ARTIFACT_BYTES)
|
|
599
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition journal exceeds the bounded size.");
|
|
600
|
+
let value;
|
|
601
|
+
try {
|
|
602
|
+
value = JSON.parse(source);
|
|
603
|
+
}
|
|
604
|
+
catch (cause) {
|
|
605
|
+
return fail("RUN_POLICY_TRANSITION_ARTIFACT_CHANGED", "Transition journal is not valid JSON.");
|
|
606
|
+
}
|
|
607
|
+
return validateJournal(value, state.runId, id);
|
|
608
|
+
}
|
|
609
|
+
async function writeJournal(state, journal) {
|
|
610
|
+
await writePrivateFile(transitionAbsolutePath(state, journal.transitionId), canonicalJson(journal), state.commonDir);
|
|
611
|
+
}
|
|
612
|
+
function stateTaskIds(state) {
|
|
613
|
+
return [...state.tasks].map(task => task.taskId).sort();
|
|
614
|
+
}
|
|
615
|
+
function stateWorkerMap(state) {
|
|
616
|
+
return new Map(state.workers.map(worker => [worker.workerId, worker]));
|
|
617
|
+
}
|
|
618
|
+
function journalWorkerMap(journal) {
|
|
619
|
+
return new Map(journal.workers.map(worker => [worker.workerId, worker]));
|
|
620
|
+
}
|
|
621
|
+
function assertWorkerIdentity(state, journal) {
|
|
622
|
+
const current = stateWorkerMap(state);
|
|
623
|
+
const expected = journalWorkerMap(journal);
|
|
624
|
+
for (const worker of journal.workerGenerations) {
|
|
625
|
+
const saved = current.get(worker.workerId);
|
|
626
|
+
const captured = expected.get(worker.workerId);
|
|
627
|
+
if (saved === undefined || saved.generation !== worker.generation || captured === undefined || saved.taskId !== captured.taskId) {
|
|
628
|
+
fail("RUN_POLICY_TRANSITION_STALE", `Worker ${worker.workerId} changed during the policy transition.`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
for (const worker of state.workers) {
|
|
632
|
+
if (!expected.has(worker.workerId) && ACTIVE_WORKER_STATUSES.has(worker.status)) {
|
|
633
|
+
fail("RUN_POLICY_TRANSITION_WRITER_ACTIVE", `A worker not captured by the transition is active: ${worker.workerId}.`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function assertTaskTree(state, journal) {
|
|
638
|
+
if (!sameJson(stateTaskIds(state), journal.taskIds)) {
|
|
639
|
+
fail("RUN_POLICY_TRANSITION_STALE", "Run task tree changed during the policy transition.");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function assertOldContract(state, journal) {
|
|
643
|
+
assertTaskTree(state, journal);
|
|
644
|
+
if (state.currentContractHash !== journal.oldContractHash || state.contractRevision !== journal.oldContractRevision) {
|
|
645
|
+
fail("RUN_POLICY_TRANSITION_STALE", "Run contract changed before policy binding.");
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function assertWritersFenced(state, journal) {
|
|
649
|
+
assertWorkerIdentity(state, journal);
|
|
650
|
+
const expected = journalWorkerMap(journal);
|
|
651
|
+
for (const worker of state.workers) {
|
|
652
|
+
if (!expected.has(worker.workerId))
|
|
653
|
+
continue;
|
|
654
|
+
if (ACTIVE_WORKER_STATUSES.has(worker.status) || !FENCED_WORKER_STATUSES.has(worker.status)) {
|
|
655
|
+
fail("RUN_POLICY_TRANSITION_FENCE_REQUIRED", `Worker ${worker.workerId} is not fenced.`);
|
|
656
|
+
}
|
|
657
|
+
if (worker.processId !== null && worker.stopIntent?.confirmedDeadAt == null) {
|
|
658
|
+
fail("RUN_POLICY_TRANSITION_FENCE_REQUIRED", `Worker ${worker.workerId} has no confirmed process death.`);
|
|
659
|
+
}
|
|
660
|
+
if (worker.proofProcess != null)
|
|
661
|
+
fail("RUN_POLICY_TRANSITION_FENCE_REQUIRED", "Worker proof process death must be confirmed before policy changes.");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function assertCurrentPolicy(state, expected) {
|
|
665
|
+
if (!artifactMatches(policyArtifactRefFromState(state), expected)) {
|
|
666
|
+
fail("RUN_POLICY_TRANSITION_STALE", "The run policy binding does not match the transition journal.");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function validateSynchronizationInput(state, journal, input) {
|
|
670
|
+
if (!Object.prototype.hasOwnProperty.call(input, "contractHash") || input.contractHash === undefined ||
|
|
671
|
+
!Object.prototype.hasOwnProperty.call(input, "contractRevision") || input.contractRevision === undefined ||
|
|
672
|
+
!Object.prototype.hasOwnProperty.call(input, "taskIds") || input.taskIds === undefined) {
|
|
673
|
+
fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Synchronization must provide the current contract revision and task IDs.");
|
|
674
|
+
}
|
|
675
|
+
const contractHash = input.contractHash === null ? null : digest(input.contractHash, "synchronized contractHash");
|
|
676
|
+
const contractRevision = input.contractRevision;
|
|
677
|
+
if (!Number.isSafeInteger(contractRevision) || contractRevision < 0 || state.currentContractHash !== contractHash || state.contractRevision !== contractRevision) {
|
|
678
|
+
fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Synchronized contract proof is stale.");
|
|
679
|
+
}
|
|
680
|
+
const taskIds = validateTaskIds(input.taskIds, "synchronized taskIds");
|
|
681
|
+
if (!sameJson(taskIds, stateTaskIds(state)) || !sameJson(taskIds, journal.taskIds)) {
|
|
682
|
+
fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Synchronized task proof does not cover the captured task tree.");
|
|
683
|
+
}
|
|
684
|
+
const calculated = sha256(canonicalJson({
|
|
685
|
+
policyArtifact: journal.newPolicyArtifact,
|
|
686
|
+
contractHash,
|
|
687
|
+
contractRevision,
|
|
688
|
+
taskIds
|
|
689
|
+
}));
|
|
690
|
+
if (input.synchronizationDigest !== undefined && input.synchronizationDigest !== calculated) {
|
|
691
|
+
fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Synchronization digest does not match the current policy and task proof.");
|
|
692
|
+
}
|
|
693
|
+
return { contractHash, contractRevision, taskIds, synchronizationDigest: calculated };
|
|
694
|
+
}
|
|
695
|
+
function newStage(journal, input) {
|
|
696
|
+
const previous = journal.stages[journal.stages.length - 1];
|
|
697
|
+
const stage = stageCore({
|
|
698
|
+
...input,
|
|
699
|
+
previousStageDigest: previous?.stageDigest ?? null
|
|
700
|
+
});
|
|
701
|
+
const { artifactDigest: _oldArtifactDigest, ...withoutDigest } = journal;
|
|
702
|
+
const base = {
|
|
703
|
+
...withoutDigest,
|
|
704
|
+
newPolicyArtifact: input.newPolicyArtifact ?? journal.newPolicyArtifact,
|
|
705
|
+
stages: [...journal.stages, stage]
|
|
706
|
+
};
|
|
707
|
+
return sealJournal(base);
|
|
708
|
+
}
|
|
709
|
+
function stageInputArtifact(state, input) {
|
|
710
|
+
if (input.newPolicyArtifact !== undefined)
|
|
711
|
+
return artifactReference(input.newPolicyArtifact, "new policy artifact");
|
|
712
|
+
return policyArtifactRefFromState(state);
|
|
713
|
+
}
|
|
714
|
+
function assertNewArtifactMatchesState(state, journal, artifact) {
|
|
715
|
+
if (artifact === null)
|
|
716
|
+
return fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "A bound policy artifact is required.");
|
|
717
|
+
const current = policyArtifactRefFromState(state);
|
|
718
|
+
if (current === null || !artifactMatches(current, artifact)) {
|
|
719
|
+
fail("RUN_POLICY_TRANSITION_STALE", "The bound policy artifact is not the current run policy.");
|
|
720
|
+
}
|
|
721
|
+
if (journal.oldPolicyArtifact !== null && journal.oldPolicyArtifact.artifactDigest === artifact.artifactDigest) {
|
|
722
|
+
fail("RUN_POLICY_TRANSITION_STALE", "The bound policy artifact did not change from the old policy.");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/** Begin a policy revision by sealing all identities that the coordinator must fence. */
|
|
726
|
+
export async function beginPolicyTransition(repository, runId, input = {}) {
|
|
727
|
+
const state = await repository.read(runId);
|
|
728
|
+
if (state === null)
|
|
729
|
+
return fail("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
730
|
+
const pending = (await discoverPendingPolicyTransitions(repository, runId));
|
|
731
|
+
if (pending.length > 0) {
|
|
732
|
+
if (input.transitionId !== undefined) {
|
|
733
|
+
const existing = pending.find(candidate => candidate.transitionId === input.transitionId);
|
|
734
|
+
if (existing !== undefined)
|
|
735
|
+
return existing;
|
|
736
|
+
}
|
|
737
|
+
return fail("RUN_POLICY_TRANSITION_PENDING", "Recover the existing policy transition before beginning another one.");
|
|
738
|
+
}
|
|
739
|
+
const id = transitionId(input.transitionId ?? `policy-transition-${randomUUID()}`);
|
|
740
|
+
const oldPolicyArtifact = policyArtifactRefFromState(state);
|
|
741
|
+
if (input.expectedOldArtifactDigest !== undefined && input.expectedOldArtifactDigest !== (oldPolicyArtifact?.artifactDigest ?? null)) {
|
|
742
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "The expected old policy artifact does not match the run.");
|
|
743
|
+
}
|
|
744
|
+
if (input.expectedContractHash !== undefined && input.expectedContractHash !== state.currentContractHash) {
|
|
745
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "The expected run contract does not match the run.");
|
|
746
|
+
}
|
|
747
|
+
if (state.status !== "active" || state.disableRestart || state.awaitingResume) {
|
|
748
|
+
return fail("RUN_POLICY_TRANSITION_DISABLED", "Only an active resumable run can begin a policy transition.");
|
|
749
|
+
}
|
|
750
|
+
if (state.integration !== null) {
|
|
751
|
+
return fail("RUN_POLICY_TRANSITION_INTEGRATION_PENDING", "Resolve the run integration journal before changing policy.");
|
|
752
|
+
}
|
|
753
|
+
const createdAt = nowIso(input.now);
|
|
754
|
+
const tasks = snapshotTasks(state);
|
|
755
|
+
const workers = snapshotWorkers(state);
|
|
756
|
+
const journal = sealJournal({
|
|
757
|
+
schemaVersion: 1,
|
|
758
|
+
runId,
|
|
759
|
+
transitionId: id,
|
|
760
|
+
createdAt,
|
|
761
|
+
baseRevision: state.revision ?? 0,
|
|
762
|
+
oldPolicyArtifact,
|
|
763
|
+
newPolicyArtifact: null,
|
|
764
|
+
oldContractHash: state.currentContractHash,
|
|
765
|
+
oldContractRevision: state.contractRevision,
|
|
766
|
+
taskIds: tasks.map(task => task.taskId).sort(),
|
|
767
|
+
tasks,
|
|
768
|
+
workerGenerations: workers.map(worker => ({ workerId: worker.workerId, generation: worker.generation })),
|
|
769
|
+
workers,
|
|
770
|
+
resumeIds: resumeIds(state),
|
|
771
|
+
deliveredMarkers: deliveredMarkers(state),
|
|
772
|
+
renewals: [],
|
|
773
|
+
stages: [stageCore({ stage: "prepared", at: createdAt, previousStageDigest: null })]
|
|
774
|
+
});
|
|
775
|
+
await repository.mutate(runId, async (current) => {
|
|
776
|
+
if ((current.revision ?? 0) !== (state.revision ?? 0) || current.status !== "active" || current.disableRestart || current.awaitingResume ||
|
|
777
|
+
!artifactMatches(policyArtifactRefFromState(current), oldPolicyArtifact) || current.currentContractHash !== state.currentContractHash ||
|
|
778
|
+
current.contractRevision !== state.contractRevision || !sameJson(stateTaskIds(current), journal.taskIds)) {
|
|
779
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "Run changed while preparing the policy transition.");
|
|
780
|
+
}
|
|
781
|
+
assertWorkerIdentity(current, journal);
|
|
782
|
+
await writeJournal(current, journal);
|
|
783
|
+
return withTransitionEvent(current, journal);
|
|
784
|
+
});
|
|
785
|
+
return journal;
|
|
786
|
+
}
|
|
787
|
+
async function markStageInternal(repository, runId, id, input) {
|
|
788
|
+
const requested = input.stage;
|
|
789
|
+
const requestedIndex = stageIndex(requested);
|
|
790
|
+
if (requestedIndex < 1)
|
|
791
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", "Prepared is created by beginPolicyTransition.");
|
|
792
|
+
const state = await repository.read(runId);
|
|
793
|
+
if (state === null)
|
|
794
|
+
return fail("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
795
|
+
const currentJournal = await readJournal(state, id);
|
|
796
|
+
if (currentJournal === null)
|
|
797
|
+
return fail("RUN_POLICY_TRANSITION_NOT_FOUND", `Transition not found: ${id}`);
|
|
798
|
+
const currentIndex = stageIndex(stageAt(currentJournal));
|
|
799
|
+
if (requestedIndex < currentIndex)
|
|
800
|
+
return fail("RUN_POLICY_TRANSITION_STAGE_ORDER", "A policy transition cannot move backwards.");
|
|
801
|
+
const at = nowIso(input.now);
|
|
802
|
+
let resulting = currentJournal;
|
|
803
|
+
await repository.mutate(runId, async (current) => {
|
|
804
|
+
const journal = await readJournal(current, id);
|
|
805
|
+
if (journal === null)
|
|
806
|
+
return fail("RUN_POLICY_TRANSITION_NOT_FOUND", `Transition not found: ${id}`);
|
|
807
|
+
const latestIndex = stageIndex(stageAt(journal));
|
|
808
|
+
if (requestedIndex < latestIndex)
|
|
809
|
+
return fail("RUN_POLICY_TRANSITION_STAGE_ORDER", "A policy transition cannot move backwards.");
|
|
810
|
+
if (requestedIndex === latestIndex) {
|
|
811
|
+
if (requested === "bound") {
|
|
812
|
+
assertNewArtifactMatchesState(current, journal, stageInputArtifact(current, input));
|
|
813
|
+
}
|
|
814
|
+
else if (requested === "synchronized") {
|
|
815
|
+
assertWritersFenced(current, journal);
|
|
816
|
+
assertCurrentPolicy(current, journal.newPolicyArtifact);
|
|
817
|
+
assertTaskTree(current, journal);
|
|
818
|
+
validateSynchronizationInput(current, journal, input);
|
|
819
|
+
}
|
|
820
|
+
else if (requested === "fenced") {
|
|
821
|
+
assertCurrentPolicy(current, journal.oldPolicyArtifact);
|
|
822
|
+
assertOldContract(current, journal);
|
|
823
|
+
assertWritersFenced(current, journal);
|
|
824
|
+
}
|
|
825
|
+
resulting = hasStageReference(current, journal) ? journal : journal;
|
|
826
|
+
return withTransitionEvent(current, journal);
|
|
827
|
+
}
|
|
828
|
+
if (requestedIndex !== latestIndex + 1)
|
|
829
|
+
return fail("RUN_POLICY_TRANSITION_STAGE_ORDER", "A policy transition stage is missing.");
|
|
830
|
+
if (requested === "fenced") {
|
|
831
|
+
assertCurrentPolicy(current, journal.oldPolicyArtifact);
|
|
832
|
+
assertOldContract(current, journal);
|
|
833
|
+
assertWritersFenced(current, journal);
|
|
834
|
+
}
|
|
835
|
+
else if (requested === "bound") {
|
|
836
|
+
assertWritersFenced(current, journal);
|
|
837
|
+
assertOldContract(current, journal);
|
|
838
|
+
const artifact = stageInputArtifact(current, input);
|
|
839
|
+
assertNewArtifactMatchesState(current, journal, artifact);
|
|
840
|
+
resulting = newStage(journal, { stage: "bound", at, newPolicyArtifact: artifact });
|
|
841
|
+
}
|
|
842
|
+
else if (requested === "synchronized") {
|
|
843
|
+
if (journal.newPolicyArtifact === null)
|
|
844
|
+
return fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "The bound policy artifact is missing.");
|
|
845
|
+
assertWritersFenced(current, journal);
|
|
846
|
+
assertCurrentPolicy(current, journal.newPolicyArtifact);
|
|
847
|
+
assertTaskTree(current, journal);
|
|
848
|
+
const proof = validateSynchronizationInput(current, journal, input);
|
|
849
|
+
resulting = newStage(journal, {
|
|
850
|
+
stage: "synchronized",
|
|
851
|
+
at,
|
|
852
|
+
newPolicyArtifact: journal.newPolicyArtifact,
|
|
853
|
+
contractHash: proof.contractHash,
|
|
854
|
+
contractRevision: proof.contractRevision,
|
|
855
|
+
taskIds: proof.taskIds,
|
|
856
|
+
synchronizationDigest: proof.synchronizationDigest
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
return fail("RUN_POLICY_TRANSITION_STAGE_ORDER", "Complete must use completePolicyTransition after synchronization.");
|
|
861
|
+
}
|
|
862
|
+
if (requested === "fenced")
|
|
863
|
+
resulting = newStage(journal, { stage: "fenced", at });
|
|
864
|
+
await writeJournal(current, resulting);
|
|
865
|
+
return withTransitionEvent(current, resulting);
|
|
866
|
+
});
|
|
867
|
+
return resulting;
|
|
868
|
+
}
|
|
869
|
+
/** Append fenced/bound/synchronized journal stages. */
|
|
870
|
+
export async function markPolicyTransitionStage(repository, runId, transitionIdValue, input) {
|
|
871
|
+
const id = transitionId(transitionIdValue);
|
|
872
|
+
if (input.stage === "complete")
|
|
873
|
+
return await completePolicyTransition(repository, runId, id, input);
|
|
874
|
+
return await markStageInternal(repository, runId, id, input);
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Rebind a bound transition after its newly selected policy expired while the
|
|
878
|
+
* supervisor was down. The caller must first create the renewed policy
|
|
879
|
+
* artifact through the normal policy binder; this operation only records that
|
|
880
|
+
* already-written identity and never executes a capability or setup command.
|
|
881
|
+
*/
|
|
882
|
+
export async function renewBoundPolicyTransition(repository, runId, transitionIdValue, input) {
|
|
883
|
+
const id = transitionId(transitionIdValue);
|
|
884
|
+
const state = await repository.read(runId);
|
|
885
|
+
if (state === null)
|
|
886
|
+
return fail("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
887
|
+
const journal = await readJournal(state, id);
|
|
888
|
+
if (journal === null)
|
|
889
|
+
return fail("RUN_POLICY_TRANSITION_NOT_FOUND", `Transition not found: ${id}`);
|
|
890
|
+
if (stageAt(journal) !== "bound") {
|
|
891
|
+
return fail("RUN_POLICY_TRANSITION_STAGE_ORDER", "Only a bound policy transition can be renewed.");
|
|
892
|
+
}
|
|
893
|
+
if (state.status !== "active" || state.disableRestart || state.awaitingResume) {
|
|
894
|
+
return fail("RUN_POLICY_TRANSITION_DISABLED", "Policy renewal requires an explicitly resumed active run.");
|
|
895
|
+
}
|
|
896
|
+
if (input.expectedJournalDigest !== undefined && input.expectedJournalDigest !== journal.artifactDigest) {
|
|
897
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "The policy transition journal changed before renewal.");
|
|
898
|
+
}
|
|
899
|
+
const previousPolicyArtifact = journal.newPolicyArtifact;
|
|
900
|
+
if (previousPolicyArtifact === null) {
|
|
901
|
+
return fail("RUN_POLICY_TRANSITION_RENEWAL_REQUIRED", "A bound transition has no policy artifact to renew.");
|
|
902
|
+
}
|
|
903
|
+
const renewedPolicyArtifact = artifactReference(input.renewedPolicyArtifact, "renewed policy artifact");
|
|
904
|
+
if (renewedPolicyArtifact.artifactPath !== policyArtifactPath(runId, renewedPolicyArtifact.artifactDigest)) {
|
|
905
|
+
return fail("RUN_POLICY_TRANSITION_INVALID", "Renewed policy artifact path is not bound to this run.");
|
|
906
|
+
}
|
|
907
|
+
const currentPolicy = policyArtifactRefFromState(state);
|
|
908
|
+
if (currentPolicy === null || !artifactMatches(currentPolicy, renewedPolicyArtifact)) {
|
|
909
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "The renewed policy artifact is not the current run binding.");
|
|
910
|
+
}
|
|
911
|
+
const at = nowIso(input.now);
|
|
912
|
+
const nowMs = Date.parse(at);
|
|
913
|
+
if (Date.parse(previousPolicyArtifact.expiresAt) > nowMs) {
|
|
914
|
+
return fail("RUN_POLICY_TRANSITION_RENEWAL_NOT_NEEDED", "The bound policy artifact has not expired.");
|
|
915
|
+
}
|
|
916
|
+
if (Date.parse(renewedPolicyArtifact.expiresAt) <= nowMs) {
|
|
917
|
+
return fail("RUN_POLICY_TRANSITION_RENEWAL_EXPIRED", "The renewed policy artifact is already expired.");
|
|
918
|
+
}
|
|
919
|
+
if (renewedPolicyArtifact.artifactDigest === previousPolicyArtifact.artifactDigest ||
|
|
920
|
+
renewedPolicyArtifact.configHash !== previousPolicyArtifact.configHash ||
|
|
921
|
+
renewedPolicyArtifact.runtimeHash !== previousPolicyArtifact.runtimeHash) {
|
|
922
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "Renewal must preserve the bound policy definition and executable runtime.");
|
|
923
|
+
}
|
|
924
|
+
const renewals = journal.renewals;
|
|
925
|
+
const previousRenewalDigest = renewals.at(-1)?.renewalDigest ?? null;
|
|
926
|
+
const renewal = sealRenewal({
|
|
927
|
+
previousPolicyArtifact,
|
|
928
|
+
renewedPolicyArtifact,
|
|
929
|
+
at,
|
|
930
|
+
previousJournalDigest: journal.artifactDigest,
|
|
931
|
+
previousRenewalDigest
|
|
932
|
+
});
|
|
933
|
+
const { artifactDigest: _journalDigest, ...withoutDigest } = journal;
|
|
934
|
+
const next = sealJournal({
|
|
935
|
+
...withoutDigest,
|
|
936
|
+
newPolicyArtifact: renewedPolicyArtifact,
|
|
937
|
+
renewals: [...renewals, renewal]
|
|
938
|
+
});
|
|
939
|
+
await repository.mutate(runId, async (current) => {
|
|
940
|
+
if ((current.revision ?? 0) !== (state.revision ?? 0) || current.status !== "active" || current.disableRestart || current.awaitingResume) {
|
|
941
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "The run changed before policy renewal was recorded.");
|
|
942
|
+
}
|
|
943
|
+
const latest = await readJournal(current, id);
|
|
944
|
+
if (latest === null || latest.artifactDigest !== journal.artifactDigest || stageAt(latest) !== "bound") {
|
|
945
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "The policy transition journal changed before renewal was recorded.");
|
|
946
|
+
}
|
|
947
|
+
if (!artifactMatches(policyArtifactRefFromState(current), renewedPolicyArtifact)) {
|
|
948
|
+
return fail("RUN_POLICY_TRANSITION_STALE", "The renewed policy binding changed before journal renewal was recorded.");
|
|
949
|
+
}
|
|
950
|
+
await writeJournal(current, next);
|
|
951
|
+
return withRenewalEvent(current, next);
|
|
952
|
+
});
|
|
953
|
+
return next;
|
|
954
|
+
}
|
|
955
|
+
/** Complete only after the synchronized policy/task proof is durable. */
|
|
956
|
+
export async function completePolicyTransition(repository, runId, transitionIdValue, input = {}) {
|
|
957
|
+
const id = transitionId(transitionIdValue);
|
|
958
|
+
const state = await repository.read(runId);
|
|
959
|
+
if (state === null)
|
|
960
|
+
return fail("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
961
|
+
const before = await readJournal(state, id);
|
|
962
|
+
if (before === null)
|
|
963
|
+
return fail("RUN_POLICY_TRANSITION_NOT_FOUND", `Transition not found: ${id}`);
|
|
964
|
+
if (stageAt(before) === "complete")
|
|
965
|
+
return before;
|
|
966
|
+
if (stageAt(before) !== "synchronized")
|
|
967
|
+
return fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Policy transition synchronization is required before completion.");
|
|
968
|
+
const sync = before.stages[before.stages.length - 1];
|
|
969
|
+
if (sync.synchronizationDigest === undefined)
|
|
970
|
+
return fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Synchronized proof is missing.");
|
|
971
|
+
if (input.synchronizationDigest !== undefined && input.synchronizationDigest !== sync.synchronizationDigest) {
|
|
972
|
+
return fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Completion proof does not match synchronized policy/task evidence.");
|
|
973
|
+
}
|
|
974
|
+
const at = nowIso(input.now);
|
|
975
|
+
let resulting = before;
|
|
976
|
+
await repository.mutate(runId, async (current) => {
|
|
977
|
+
const journal = await readJournal(current, id);
|
|
978
|
+
if (journal === null)
|
|
979
|
+
return fail("RUN_POLICY_TRANSITION_NOT_FOUND", `Transition not found: ${id}`);
|
|
980
|
+
if (stageAt(journal) === "complete") {
|
|
981
|
+
resulting = journal;
|
|
982
|
+
return current;
|
|
983
|
+
}
|
|
984
|
+
if (stageAt(journal) !== "synchronized")
|
|
985
|
+
return fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Policy transition synchronization is required before completion.");
|
|
986
|
+
const stage = journal.stages[journal.stages.length - 1];
|
|
987
|
+
if (stage.synchronizationDigest === undefined ||
|
|
988
|
+
(input.synchronizationDigest !== undefined && input.synchronizationDigest !== stage.synchronizationDigest)) {
|
|
989
|
+
return fail("RUN_POLICY_TRANSITION_SYNC_REQUIRED", "Completion proof does not match synchronized policy/task evidence.");
|
|
990
|
+
}
|
|
991
|
+
assertWritersFenced(current, journal);
|
|
992
|
+
assertCurrentPolicy(current, journal.newPolicyArtifact);
|
|
993
|
+
assertTaskTree(current, journal);
|
|
994
|
+
const syncInput = {
|
|
995
|
+
stage: "synchronized",
|
|
996
|
+
contractHash: stage.contractHash,
|
|
997
|
+
contractRevision: stage.contractRevision,
|
|
998
|
+
taskIds: stage.taskIds,
|
|
999
|
+
synchronizationDigest: stage.synchronizationDigest
|
|
1000
|
+
};
|
|
1001
|
+
validateSynchronizationInput(current, journal, syncInput);
|
|
1002
|
+
resulting = newStage(journal, { stage: "complete", at });
|
|
1003
|
+
await writeJournal(current, resulting);
|
|
1004
|
+
return withTransitionEvent(current, resulting);
|
|
1005
|
+
});
|
|
1006
|
+
return resulting;
|
|
1007
|
+
}
|
|
1008
|
+
/** Read one private journal without exposing policy configuration contents. */
|
|
1009
|
+
export async function readPolicyTransition(repository, runId, transitionIdValue) {
|
|
1010
|
+
const id = transitionId(transitionIdValue);
|
|
1011
|
+
const state = await repository.read(runId);
|
|
1012
|
+
if (state === null)
|
|
1013
|
+
return fail("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
1014
|
+
return await readJournal(state, id);
|
|
1015
|
+
}
|
|
1016
|
+
/** Discover all valid journals, including completed history. */
|
|
1017
|
+
export async function discoverPolicyTransitions(repository, runId) {
|
|
1018
|
+
const state = await repository.read(runId);
|
|
1019
|
+
if (state === null)
|
|
1020
|
+
return fail("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
1021
|
+
const directory = transitionDirectory(state);
|
|
1022
|
+
try {
|
|
1023
|
+
await ensurePrivateDirectory(directory, state.commonDir);
|
|
1024
|
+
}
|
|
1025
|
+
catch (error) {
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
let names;
|
|
1029
|
+
try {
|
|
1030
|
+
names = await readdir(directory);
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
|
|
1034
|
+
return [];
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
const journals = [];
|
|
1038
|
+
for (const name of names.filter(candidate => candidate.endsWith(".json") && ID.test(candidate.slice(0, -5)))) {
|
|
1039
|
+
const id = candidateId(name);
|
|
1040
|
+
const journal = await readJournal(state, id);
|
|
1041
|
+
if (journal !== null)
|
|
1042
|
+
journals.push(journal);
|
|
1043
|
+
}
|
|
1044
|
+
return journals.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.transitionId.localeCompare(right.transitionId));
|
|
1045
|
+
}
|
|
1046
|
+
function candidateId(name) {
|
|
1047
|
+
return transitionId(name.slice(0, -5));
|
|
1048
|
+
}
|
|
1049
|
+
/** Discover incomplete journals that must be recovered before another transition. */
|
|
1050
|
+
export async function discoverPendingPolicyTransitions(repository, runId) {
|
|
1051
|
+
return (await discoverPolicyTransitions(repository, runId)).filter(journal => stageAt(journal) !== "complete");
|
|
1052
|
+
}
|
|
1053
|
+
/** Return one pending journal, failing closed if recovery is ambiguous. */
|
|
1054
|
+
export async function discoverPendingPolicyTransition(repository, runId) {
|
|
1055
|
+
const pending = await discoverPendingPolicyTransitions(repository, runId);
|
|
1056
|
+
if (pending.length > 1)
|
|
1057
|
+
return fail("RUN_POLICY_TRANSITION_PENDING", "Multiple policy transitions require explicit recovery.");
|
|
1058
|
+
return pending[0] ?? null;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Old generations are blocked for the whole pending window. The coordinator
|
|
1062
|
+
* can use this at delivery/restart boundaries; this module never starts or
|
|
1063
|
+
* stops a native writer itself.
|
|
1064
|
+
*/
|
|
1065
|
+
export function policyTransitionBlocksWorker(journal, workerId, generation) {
|
|
1066
|
+
if (stageAt(journal) === "complete")
|
|
1067
|
+
return false;
|
|
1068
|
+
// A restarted worker with a new generation is still unsafe until the
|
|
1069
|
+
// synchronization and complete markers are durable. The generation is
|
|
1070
|
+
// retained in the journal for diagnostics and stale-event rejection.
|
|
1071
|
+
return journal.workerGenerations.some(worker => worker.workerId === workerId);
|
|
1072
|
+
}
|
|
1073
|
+
/** A pending transition is restart-safe only after its complete marker exists. */
|
|
1074
|
+
export function policyTransitionAllowsRestart(journal) {
|
|
1075
|
+
return stageAt(journal) === "complete";
|
|
1076
|
+
}
|