opencode-feature-factory 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +877 -0
- package/assets/agent/backend-builder.md +73 -0
- package/assets/agent/codebase-researcher.md +56 -0
- package/assets/agent/design-interpreter.md +37 -0
- package/assets/agent/frontend-builder.md +74 -0
- package/assets/agent/implementation-validator.md +73 -0
- package/assets/agent/security-reviewer.md +60 -0
- package/assets/agent/spec-writer.md +94 -0
- package/assets/agent/story-reader.md +38 -0
- package/assets/agent/story-writer.md +39 -0
- package/assets/agent/test-verifier.md +73 -0
- package/assets/agent/work-decomposer.md +102 -0
- package/assets/agent/work-reviewer.md +77 -0
- package/assets/command/feature.md +68 -0
- package/assets/skills/feature/SCHEMA.md +990 -0
- package/assets/skills/feature/SKILL.md +620 -0
- package/dist/tui.js +3641 -0
- package/package.json +65 -0
- package/src/cleanup-sweep-command.js +75 -0
- package/src/cleanup-sweep-eligibility.js +581 -0
- package/src/cleanup-sweep-output.js +139 -0
- package/src/cleanup-sweep-report.js +548 -0
- package/src/cleanup-sweep.js +546 -0
- package/src/cli-output.js +251 -0
- package/src/cli.js +1152 -0
- package/src/config.js +231 -0
- package/src/cost-attribution.js +327 -0
- package/src/cost-report.js +185 -0
- package/src/detached-log-supervisor.js +178 -0
- package/src/doctor.js +598 -0
- package/src/env-snapshot.js +211 -0
- package/src/factory-diagnostics.js +429 -0
- package/src/factory-paths.js +40 -0
- package/src/factory.js +4769 -0
- package/src/feature-command-payload.js +378 -0
- package/src/git.js +110 -0
- package/src/github.js +252 -0
- package/src/hardening/atomic-write.js +954 -0
- package/src/hardening/line-output.js +139 -0
- package/src/hardening/output-policy.js +365 -0
- package/src/hardening/process-verification.js +542 -0
- package/src/hardening/sensitive-data.js +341 -0
- package/src/hardening/terminal-encoding.js +224 -0
- package/src/plugin.js +246 -0
- package/src/post-pr-ci.js +754 -0
- package/src/process-evidence.js +1139 -0
- package/src/refs.js +144 -0
- package/src/run-state.js +2411 -0
- package/src/steering-conflicts.js +77 -0
- package/src/telemetry.js +419 -0
- package/src/tui-data.js +499 -0
- package/src/tui-rendering.js +148 -0
- package/src/utils.js +35 -0
- package/src/validate.js +1655 -0
- package/src/worktrees.js +56 -0
package/src/run-state.js
ADDED
|
@@ -0,0 +1,2411 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { lstat, open, readFile, rename, rm, mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { appendCostAttributionEntry } from "./cost-attribution.js";
|
|
7
|
+
import { git } from "./git.js";
|
|
8
|
+
import { probeLegacyBooleanLiveness } from "./hardening/process-verification.js";
|
|
9
|
+
import { canonicalizeGithubPrUrl, githubPrUrlParts, hashFile, hashValue, resolveArtifactRef, resolveEvidenceRef, resolveGateRef, resolveReviewRef, resolveSteeringRef } from "./refs.js";
|
|
10
|
+
import { buildSteeringConflictTerminalResult, collectProtectedSteeringState } from "./steering-conflicts.js";
|
|
11
|
+
import { PASSING_SECURITY_VERDICTS, PASSING_VALIDATOR_VERDICTS, POST_PR_TERMINAL_REASONS, pendingProtectedGate, postPrConsistencyChecks, validateHeartbeatState, validateRun } from "./validate.js";
|
|
12
|
+
import { requireNonEmptyString, timestamp } from "./utils.js";
|
|
13
|
+
|
|
14
|
+
export const TERMINAL_RUN_STATUSES = new Set(["completed", "blocked", "partial", "needs-human"]);
|
|
15
|
+
|
|
16
|
+
const HEARTBEAT_STEP_IN_FLIGHT_STATUSES = new Set(["running"]);
|
|
17
|
+
const HEARTBEAT_SLICE_IN_FLIGHT_STATUSES = new Set(["running", "review"]);
|
|
18
|
+
const SAFE_GATE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/u;
|
|
19
|
+
const GATE_DECISION_STATUSES = new Set(["approved", "changes_requested", "stopped"]);
|
|
20
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 1000;
|
|
21
|
+
const DEFAULT_LOCK_RETRY_DELAY_MS = 10;
|
|
22
|
+
const DEFAULT_STALE_LOCK_MS = 60000;
|
|
23
|
+
const DEFAULT_MISSING_OWNER_STEAL_MS = 5000;
|
|
24
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
|
|
25
|
+
const MIN_STALE_HEARTBEAT_MS = 120000;
|
|
26
|
+
const LOCK_DIR = "run-json.lock";
|
|
27
|
+
const LOCK_OWNER_FILE = "owner.json";
|
|
28
|
+
const RUN_FILE = "run.json";
|
|
29
|
+
const HEARTBEAT_FILE = "heartbeat.json";
|
|
30
|
+
const STEERING_BOUNDARY_KINDS = new Set(["gate", "dispatch", "remediation", "terminal", "post-pr-observe", "post-pr-push"]);
|
|
31
|
+
const STEERING_ACTION_KINDS = new Set(["dispatch", "remediation", "terminal", "post-pr-observe", "post-pr-push"]);
|
|
32
|
+
const POST_PR_HEARTBEAT_PHASES = new Set(["observing", "remediation-running", "revalidating"]);
|
|
33
|
+
const POST_PR_TERMINAL_PHASE = Object.freeze({ completed: "succeeded", blocked: "blocked", "needs-human": "needs-human" });
|
|
34
|
+
const POST_PR_TRANSITIONS = new Map([
|
|
35
|
+
["awaiting-pr", new Set(["observing"])],
|
|
36
|
+
["observing", new Set(["observing", "failure-recording", "succeeded", "blocked", "needs-human"])],
|
|
37
|
+
["failure-recording", new Set(["failure-recording", "remediation-planned", "blocked", "needs-human"])],
|
|
38
|
+
["remediation-planned", new Set(["remediation-planned", "remediation-running", "needs-human"])],
|
|
39
|
+
["remediation-running", new Set(["remediation-running", "changes-observed", "needs-human"])],
|
|
40
|
+
["changes-observed", new Set(["changes-observed", "committed", "needs-human"])],
|
|
41
|
+
["committed", new Set(["committed", "revalidating", "needs-human"])],
|
|
42
|
+
["revalidating", new Set(["revalidating", "failure-recording", "validated", "blocked", "needs-human"])],
|
|
43
|
+
["validated", new Set(["validated", "push-pending", "needs-human"])],
|
|
44
|
+
["push-pending", new Set(["push-pending", "remote-confirmed", "needs-human"])],
|
|
45
|
+
["remote-confirmed", new Set(["remote-confirmed", "observing", "needs-human"])],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export class RunJsonLockContendedError extends Error {
|
|
49
|
+
constructor(lockDir) {
|
|
50
|
+
super(`run.json lock is contended at ${lockDir}`);
|
|
51
|
+
this.name = "RunJsonLockContendedError";
|
|
52
|
+
this.code = "RUN_JSON_LOCK_CONTENDED";
|
|
53
|
+
this.lockDir = lockDir;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function withRunJsonLock(runDir, fn, options = {}) {
|
|
58
|
+
if (typeof fn !== "function") throw new Error("withRunJsonLock requires a callback");
|
|
59
|
+
const reclaimMode = normalizeReclaimMode(options.reclaimMode);
|
|
60
|
+
const lockHooks = validateRunJsonLockHooks(options.lockHooks);
|
|
61
|
+
const timeoutMs = normalizePositiveInteger(options.timeoutMs, DEFAULT_LOCK_TIMEOUT_MS);
|
|
62
|
+
const retryDelayMs = normalizePositiveInteger(options.retryDelayMs, DEFAULT_LOCK_RETRY_DELAY_MS);
|
|
63
|
+
normalizePositiveInteger(options.staleLockMs, DEFAULT_STALE_LOCK_MS);
|
|
64
|
+
normalizePositiveInteger(options.missingOwnerStealMs, DEFAULT_MISSING_OWNER_STEAL_MS);
|
|
65
|
+
const lockDir = join(runDir, LOCK_DIR);
|
|
66
|
+
const ownerPath = join(lockDir, LOCK_OWNER_FILE);
|
|
67
|
+
const deadline = Date.now() + timeoutMs;
|
|
68
|
+
let stolenFrom = null;
|
|
69
|
+
let stealAttempted = false;
|
|
70
|
+
let contentionReported = false;
|
|
71
|
+
let createdIdentity = null;
|
|
72
|
+
let owner = null;
|
|
73
|
+
let ownerPublished = false;
|
|
74
|
+
let publishedEvidence = null;
|
|
75
|
+
|
|
76
|
+
while (true) {
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(lockDir);
|
|
79
|
+
createdIdentity = await lockDirectoryIdentity(lockDir);
|
|
80
|
+
break;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code !== "EEXIST") throw error;
|
|
83
|
+
if (reclaimMode === "never") throw new RunJsonLockContendedError(lockDir);
|
|
84
|
+
if (!contentionReported && lockHooks.onContended) {
|
|
85
|
+
contentionReported = true;
|
|
86
|
+
await runContendedLockHook(lockHooks.onContended, { runDir, lockDir }, deadline, ownerPath);
|
|
87
|
+
}
|
|
88
|
+
if (!stealAttempted) {
|
|
89
|
+
const observedIdentity = await lockDirectoryIdentity(lockDir);
|
|
90
|
+
const observedEvidence = await readLockOwnerEvidence(ownerPath);
|
|
91
|
+
if (canStealRunJsonLock(observedEvidence?.owner, options)) {
|
|
92
|
+
stealAttempted = true;
|
|
93
|
+
const reclaimed = await reclaimRunJsonLock(runDir, lockDir, observedIdentity, observedEvidence, options, lockHooks, deadline);
|
|
94
|
+
if (reclaimed) {
|
|
95
|
+
stolenFrom = reclaimed;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
} else if (!observedEvidence && await canReclaimOwnerlessRunJsonLock(observedIdentity, ownerPath, options)) {
|
|
99
|
+
stealAttempted = true;
|
|
100
|
+
if (await reclaimOwnerlessRunJsonLock(runDir, lockDir, observedIdentity, options, lockHooks, deadline)) continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (Date.now() >= deadline) {
|
|
104
|
+
const observedOwner = await readLockOwner(ownerPath);
|
|
105
|
+
throw new Error(formatLockTimeout(lockDir, observedOwner));
|
|
106
|
+
}
|
|
107
|
+
await delay(Math.min(retryDelayMs, Math.max(1, deadline - Date.now())));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
owner = { pid: process.pid, hostname: hostname(), acquired_at: new Date().toISOString(), nonce: randomUUID() };
|
|
112
|
+
if (stolenFrom) owner.stolen_from = sanitizeLockOwner(stolenFrom);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
if (lockHooks.onLockCreated) {
|
|
116
|
+
await runRunJsonLockHook(lockHooks.onLockCreated, { runDir, lockDir }, deadline, ownerPath);
|
|
117
|
+
}
|
|
118
|
+
if (!sameLockDirectoryIdentity(createdIdentity, await lockDirectoryIdentity(lockDir))) {
|
|
119
|
+
throw new Error(`run.json lock ownership changed before owner publication at ${lockDir}`);
|
|
120
|
+
}
|
|
121
|
+
await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
122
|
+
publishedEvidence = await readLockOwnerEvidence(ownerPath);
|
|
123
|
+
if (!sameLockOwner(owner, publishedEvidence?.owner)) throw new Error(`run.json lock owner publication failed at ${lockDir}`);
|
|
124
|
+
ownerPublished = true;
|
|
125
|
+
return await fn({ lock_dir: lockDir, owner });
|
|
126
|
+
} finally {
|
|
127
|
+
if (ownerPublished) {
|
|
128
|
+
if (lockHooks.onBeforeCleanup) await lockHooks.onBeforeCleanup({ runDir, lockDir });
|
|
129
|
+
await releaseOwnedRunJsonLock(runDir, lockDir, createdIdentity, publishedEvidence);
|
|
130
|
+
} else if (!ownerPublished && !(await lockOwnerEntryExists(ownerPath))) {
|
|
131
|
+
await quarantineAndRemoveOwnedLock(runDir, lockDir, createdIdentity);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeReclaimMode(value) {
|
|
137
|
+
if (value === undefined || value === "dead-owner") return "dead-owner";
|
|
138
|
+
if (value === "never") return value;
|
|
139
|
+
throw new Error("reclaimMode must be dead-owner or never");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateRunJsonLockHooks(lockHooks) {
|
|
143
|
+
if (lockHooks === undefined) return {};
|
|
144
|
+
if (!isRecord(lockHooks)) throw new Error("lockHooks must be an object");
|
|
145
|
+
if (lockHooks.onContended !== undefined && typeof lockHooks.onContended !== "function") {
|
|
146
|
+
throw new Error("lockHooks.onContended must be a function");
|
|
147
|
+
}
|
|
148
|
+
if (lockHooks.onLockCreated !== undefined && typeof lockHooks.onLockCreated !== "function") {
|
|
149
|
+
throw new Error("lockHooks.onLockCreated must be a function");
|
|
150
|
+
}
|
|
151
|
+
for (const name of ["onBeforeReclaimClaim", "onReclaimClaimed", "onReclaimAbandoned", "onReclaimRenamed", "onReclaimRemoved", "onBeforeCleanup"]) {
|
|
152
|
+
if (lockHooks[name] !== undefined && typeof lockHooks[name] !== "function") {
|
|
153
|
+
throw new Error(`lockHooks.${name} must be a function`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return lockHooks;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runContendedLockHook(onContended, context, deadline, ownerPath) {
|
|
160
|
+
return runRunJsonLockHook(onContended, context, deadline, ownerPath);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runRunJsonLockHook(hook, context, deadline, ownerPath) {
|
|
164
|
+
const remainingMs = deadline - Date.now();
|
|
165
|
+
if (remainingMs <= 0) throw new Error(formatLockTimeout(context.lockDir, await readLockOwner(ownerPath)));
|
|
166
|
+
let timer;
|
|
167
|
+
try {
|
|
168
|
+
await Promise.race([
|
|
169
|
+
Promise.resolve().then(() => hook(context)),
|
|
170
|
+
new Promise((_, reject) => {
|
|
171
|
+
timer = setTimeout(async () => {
|
|
172
|
+
reject(new Error(formatLockTimeout(context.lockDir, await readLockOwner(ownerPath))));
|
|
173
|
+
}, remainingMs);
|
|
174
|
+
}),
|
|
175
|
+
]);
|
|
176
|
+
} finally {
|
|
177
|
+
if (timer) clearTimeout(timer);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function canStealRunJsonLock(owner, options = {}) {
|
|
182
|
+
return isDurableLockOwner(owner) && inspectLockOwnerLiveness(owner, options) === "dead";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function readLockOwnerEvidence(ownerPath) {
|
|
186
|
+
let handle;
|
|
187
|
+
try {
|
|
188
|
+
handle = await open(ownerPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
189
|
+
const parsed = JSON.parse(await handle.readFile("utf8"));
|
|
190
|
+
if (!isDurableLockOwner(parsed)) return null;
|
|
191
|
+
const value = await handle.stat();
|
|
192
|
+
return { owner: parsed, identity: { dev: value.dev, ino: value.ino } };
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
} finally {
|
|
196
|
+
await handle?.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function readLockOwner(ownerPath) {
|
|
201
|
+
return (await readLockOwnerEvidence(ownerPath))?.owner ?? null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function reclaimRunJsonLock(runDir, lockDir, observedIdentity, observedEvidence, options, lockHooks, deadline) {
|
|
205
|
+
if (!observedIdentity || !observedEvidence) return null;
|
|
206
|
+
const claimPath = reclaimClaimPath(runDir, observedIdentity, observedEvidence.owner.nonce);
|
|
207
|
+
const claim = { owner_nonce: observedEvidence.owner.nonce, reclaim_nonce: randomUUID() };
|
|
208
|
+
if (lockHooks.onBeforeReclaimClaim) {
|
|
209
|
+
await runRunJsonLockHook(lockHooks.onBeforeReclaimClaim, { runDir, lockDir }, deadline, join(lockDir, LOCK_OWNER_FILE));
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
await writeFile(claimPath, `${JSON.stringify(claim)}\n`, { encoding: "utf8", flag: "wx" });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error?.code === "EEXIST" || error?.code === "ENOENT") return null;
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
if (lockHooks.onReclaimClaimed) {
|
|
218
|
+
await runRunJsonLockHook(lockHooks.onReclaimClaimed, { runDir, lockDir }, deadline, join(lockDir, LOCK_OWNER_FILE));
|
|
219
|
+
}
|
|
220
|
+
const confirmedIdentity = await lockDirectoryIdentity(lockDir);
|
|
221
|
+
const confirmedEvidence = await readLockOwnerEvidence(join(lockDir, LOCK_OWNER_FILE));
|
|
222
|
+
if (!sameLockDirectoryIdentity(observedIdentity, confirmedIdentity)
|
|
223
|
+
|| !sameLockOwnerEvidence(observedEvidence, confirmedEvidence)
|
|
224
|
+
|| !canStealRunJsonLock(confirmedEvidence?.owner, options)
|
|
225
|
+
|| !sameReclaimClaim(claim, await readJsonNoFollow(claimPath))) {
|
|
226
|
+
await removeOwnedReclaimClaim(claimPath, claim);
|
|
227
|
+
if (lockHooks.onReclaimAbandoned) await lockHooks.onReclaimAbandoned({ runDir, lockDir });
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const quarantine = await renameOwnedLockToQuarantine(runDir, lockDir, observedIdentity);
|
|
232
|
+
const movedOwnerPath = join(quarantine, LOCK_OWNER_FILE);
|
|
233
|
+
if (!sameLockOwnerEvidence(observedEvidence, await readLockOwnerEvidence(movedOwnerPath))
|
|
234
|
+
|| !sameReclaimClaim(claim, await readJsonNoFollow(claimPath))) {
|
|
235
|
+
throw new Error(`run.json lock reclamation identity changed at ${quarantine}`);
|
|
236
|
+
}
|
|
237
|
+
if (lockHooks.onReclaimRenamed) await lockHooks.onReclaimRenamed({ runDir, lockDir, quarantine });
|
|
238
|
+
if (!sameLockDirectoryIdentity(observedIdentity, await lockDirectoryIdentity(quarantine))
|
|
239
|
+
|| !sameLockOwnerEvidence(observedEvidence, await readLockOwnerEvidence(movedOwnerPath))
|
|
240
|
+
|| !sameReclaimClaim(claim, await readJsonNoFollow(claimPath))) {
|
|
241
|
+
throw new Error(`run.json lock reclamation identity changed before removal at ${quarantine}`);
|
|
242
|
+
}
|
|
243
|
+
if (!canStealRunJsonLock(observedEvidence.owner, options)) {
|
|
244
|
+
throw new Error(`run.json lock owner is no longer definitively dead before removal at ${quarantine}`);
|
|
245
|
+
}
|
|
246
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
247
|
+
await removeOwnedReclaimClaim(claimPath, claim);
|
|
248
|
+
if (lockHooks.onReclaimRemoved) await lockHooks.onReclaimRemoved({ runDir, lockDir });
|
|
249
|
+
return observedEvidence.owner;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function canReclaimOwnerlessRunJsonLock(identity, ownerPath, options = {}) {
|
|
253
|
+
if (!identity || await lockOwnerEntryExists(ownerPath)) return false;
|
|
254
|
+
const ageMs = Date.now() - identity.mtimeMs;
|
|
255
|
+
return Number.isFinite(ageMs) && ageMs >= normalizePositiveInteger(options.missingOwnerStealMs, DEFAULT_MISSING_OWNER_STEAL_MS);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function reclaimOwnerlessRunJsonLock(runDir, lockDir, observedIdentity, options, lockHooks, deadline) {
|
|
259
|
+
if (!observedIdentity) return false;
|
|
260
|
+
const claimPath = reclaimClaimPath(runDir, observedIdentity, "ownerless");
|
|
261
|
+
const claim = { owner_nonce: "ownerless", reclaim_nonce: randomUUID() };
|
|
262
|
+
if (lockHooks.onBeforeReclaimClaim) {
|
|
263
|
+
await runRunJsonLockHook(lockHooks.onBeforeReclaimClaim, { runDir, lockDir }, deadline, join(lockDir, LOCK_OWNER_FILE));
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
await writeFile(claimPath, `${JSON.stringify(claim)}\n`, { encoding: "utf8", flag: "wx" });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error?.code === "EEXIST" || error?.code === "ENOENT") return false;
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
if (lockHooks.onReclaimClaimed) {
|
|
272
|
+
await runRunJsonLockHook(lockHooks.onReclaimClaimed, { runDir, lockDir }, deadline, join(lockDir, LOCK_OWNER_FILE));
|
|
273
|
+
}
|
|
274
|
+
const ownerPath = join(lockDir, LOCK_OWNER_FILE);
|
|
275
|
+
if (!sameLockDirectoryIdentity(observedIdentity, await lockDirectoryIdentity(lockDir))
|
|
276
|
+
|| !sameReclaimClaim(claim, await readJsonNoFollow(claimPath))
|
|
277
|
+
|| !await canReclaimOwnerlessRunJsonLock(observedIdentity, ownerPath, options)) {
|
|
278
|
+
await removeOwnedReclaimClaim(claimPath, claim);
|
|
279
|
+
if (lockHooks.onReclaimAbandoned) await lockHooks.onReclaimAbandoned({ runDir, lockDir });
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const quarantine = await renameOwnedLockToQuarantine(runDir, lockDir, observedIdentity);
|
|
284
|
+
const movedOwnerPath = join(quarantine, LOCK_OWNER_FILE);
|
|
285
|
+
if (lockHooks.onReclaimRenamed) await lockHooks.onReclaimRenamed({ runDir, lockDir, quarantine });
|
|
286
|
+
if (!sameLockDirectoryIdentity(observedIdentity, await lockDirectoryIdentity(quarantine))
|
|
287
|
+
|| await lockOwnerEntryExists(movedOwnerPath)
|
|
288
|
+
|| !sameReclaimClaim(claim, await readJsonNoFollow(claimPath))) {
|
|
289
|
+
throw new Error(`ownerless run.json lock reclamation identity changed at ${quarantine}`);
|
|
290
|
+
}
|
|
291
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
292
|
+
await removeOwnedReclaimClaim(claimPath, claim);
|
|
293
|
+
if (lockHooks.onReclaimRemoved) await lockHooks.onReclaimRemoved({ runDir, lockDir });
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function reclaimClaimPath(runDir, identity, ownerNonce) {
|
|
298
|
+
const key = createHash("sha256").update(`${identity.dev}:${identity.ino}:${ownerNonce}`).digest("hex");
|
|
299
|
+
return join(runDir, `.run-json.lock-reclaim-${key}.json`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function removeOwnedReclaimClaim(claimPath, claim) {
|
|
303
|
+
if (!sameReclaimClaim(claim, await readJsonNoFollow(claimPath))) return;
|
|
304
|
+
await rm(claimPath, { force: true });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function releaseOwnedRunJsonLock(runDir, lockDir, expectedIdentity, expectedEvidence) {
|
|
308
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(lockDir))) return;
|
|
309
|
+
if (!sameLockOwnerEvidence(expectedEvidence, await readLockOwnerEvidence(join(lockDir, LOCK_OWNER_FILE)))) return;
|
|
310
|
+
const quarantine = await renameOwnedLockToQuarantine(runDir, lockDir, expectedIdentity);
|
|
311
|
+
if (!sameLockOwnerEvidence(expectedEvidence, await readLockOwnerEvidence(join(quarantine, LOCK_OWNER_FILE)))) {
|
|
312
|
+
throw new Error(`run.json lock cleanup identity changed at ${quarantine}`);
|
|
313
|
+
}
|
|
314
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(quarantine))
|
|
315
|
+
|| !sameLockOwnerEvidence(expectedEvidence, await readLockOwnerEvidence(join(quarantine, LOCK_OWNER_FILE)))) return;
|
|
316
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function quarantineAndRemoveOwnedLock(runDir, lockDir, expectedIdentity) {
|
|
320
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(lockDir))) return;
|
|
321
|
+
const quarantine = await renameOwnedLockToQuarantine(runDir, lockDir, expectedIdentity);
|
|
322
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(quarantine))) return;
|
|
323
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function renameOwnedLockToQuarantine(runDir, lockDir, expectedIdentity) {
|
|
327
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(lockDir))) {
|
|
328
|
+
throw new Error(`run.json lock directory identity changed at ${lockDir}`);
|
|
329
|
+
}
|
|
330
|
+
const quarantine = join(runDir, `.run-json.lock-quarantine-${randomUUID()}`);
|
|
331
|
+
await rename(lockDir, quarantine);
|
|
332
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(quarantine))) {
|
|
333
|
+
throw new Error(`run.json lock quarantine identity changed at ${quarantine}`);
|
|
334
|
+
}
|
|
335
|
+
return quarantine;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function readJsonNoFollow(path) {
|
|
339
|
+
let handle;
|
|
340
|
+
try {
|
|
341
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
342
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
} finally {
|
|
346
|
+
await handle?.close();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function sameReclaimClaim(left, right) {
|
|
351
|
+
return isRecord(left) && isRecord(right)
|
|
352
|
+
&& left.owner_nonce === right.owner_nonce
|
|
353
|
+
&& left.reclaim_nonce === right.reclaim_nonce;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function inspectLockOwnerLiveness(owner, options = {}) {
|
|
357
|
+
if (!isDurableLockOwner(owner) || owner.hostname !== hostname()) return "indeterminate";
|
|
358
|
+
if (typeof options.processAliveFn === "function") {
|
|
359
|
+
const status = probeLegacyBooleanLiveness(options.processAliveFn, owner.pid);
|
|
360
|
+
if (status === "live") return "alive";
|
|
361
|
+
if (status === "absent") return "dead";
|
|
362
|
+
return "indeterminate";
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
process.kill(owner.pid, 0);
|
|
366
|
+
return "alive";
|
|
367
|
+
} catch (error) {
|
|
368
|
+
return error?.code === "ESRCH" ? "dead" : "indeterminate";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function lockOwnerEntryExists(ownerPath) {
|
|
373
|
+
try {
|
|
374
|
+
await lstat(ownerPath);
|
|
375
|
+
return true;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (error?.code === "ENOENT") return false;
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sanitizeLockOwner(owner) {
|
|
383
|
+
if (!isRecord(owner)) return owner;
|
|
384
|
+
return Object.fromEntries(Object.entries(owner).filter(([key]) => key !== "stolen_from" && key !== "nonce"));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isDurableLockOwner(owner) {
|
|
388
|
+
return isRecord(owner)
|
|
389
|
+
&& Number.isInteger(owner.pid)
|
|
390
|
+
&& owner.pid > 0
|
|
391
|
+
&& stringValue(owner.hostname)
|
|
392
|
+
&& Number.isFinite(Date.parse(owner.acquired_at || ""))
|
|
393
|
+
&& typeof owner.nonce === "string"
|
|
394
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(owner.nonce);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function sameLockOwner(left, right) {
|
|
398
|
+
return isDurableLockOwner(left) && isDurableLockOwner(right) && left.nonce === right.nonce;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function sameLockOwnerEvidence(left, right) {
|
|
402
|
+
return Boolean(left && right
|
|
403
|
+
&& sameLockOwner(left.owner, right.owner)
|
|
404
|
+
&& left.identity.dev === right.identity.dev
|
|
405
|
+
&& left.identity.ino === right.identity.ino);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function lockDirectoryIdentity(lockDir) {
|
|
409
|
+
try {
|
|
410
|
+
const value = await lstat(lockDir);
|
|
411
|
+
if (!value.isDirectory() || value.isSymbolicLink()) return null;
|
|
412
|
+
return { dev: value.dev, ino: value.ino, mtimeMs: value.mtimeMs };
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function sameLockDirectoryIdentity(left, right) {
|
|
419
|
+
return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function hashRunState(run) {
|
|
423
|
+
return hashValue(validateRun(cloneJson(run)));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function createPostPrState(policy) {
|
|
427
|
+
const postPr = { schema_version: 1, policy: cloneJson(policy), phase: policy?.enabled === true ? "awaiting-pr" : "disabled", attempt: 0, observation: null, remediation: null, evidence_refs: [], continuation_review: null, terminal_fact: null };
|
|
428
|
+
validateRun({ schema_version: 1, run_id: "post-pr-policy-check", status: "running", max_retries: 3, gates: {}, post_pr: postPr });
|
|
429
|
+
return postPr;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function assertRunJsonWriterAllowed(run, label, options = {}) {
|
|
433
|
+
const operation = stringValue(label) ? label : "run.json writer";
|
|
434
|
+
if (!options.allowPrePrFence && isRecord(run?.steering?.pr_fence)) throw new Error(`${operation} rejected: active pre-PR fence`);
|
|
435
|
+
if (!options.allowActionClaim && isRecord(run?.steering?.action_claim)) throw new Error(`${operation} rejected: action start acknowledgement is pending`);
|
|
436
|
+
if (!options.allowUncheckpointed && isRecord(run?.steering?.uncheckpointed)) throw new Error(`${operation} rejected: consumed steering acknowledgement is pending`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function transitionRunJson(runDir, mutator, options = {}) {
|
|
440
|
+
if (typeof mutator !== "function") throw new Error("transitionRunJson requires a mutator");
|
|
441
|
+
return withRunJsonLock(runDir, async () => transitionRunJsonLocked(runDir, mutator, options), options);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function transitionGateDecision(runDir, gateName, gate, options = {}) {
|
|
445
|
+
if (!stringValue(gateName)) throw new Error("transitionGateDecision requires a gate name");
|
|
446
|
+
assertSafeGateName(gateName);
|
|
447
|
+
const redeliveryInput = {
|
|
448
|
+
answeredAtExplicit: Object.prototype.hasOwnProperty.call(gate || {}, "answered_at") || stringValue(options.answeredAt),
|
|
449
|
+
rawAnswer: typeof gate?.answer === "string" ? gate.answer : null,
|
|
450
|
+
};
|
|
451
|
+
const nextGate = normalizeGateDecision(gateName, gate, options);
|
|
452
|
+
const answerArchives = [];
|
|
453
|
+
const result = await withRunJsonLock(runDir, async () => {
|
|
454
|
+
const current = await readRunJson(runDir);
|
|
455
|
+
if (nextGate.status === "approved" && current.gates?.[gateName]?.status === "approved") {
|
|
456
|
+
return reconcileApprovedGateRedelivery(runDir, current, gateName, nextGate, redeliveryInput);
|
|
457
|
+
}
|
|
458
|
+
return transitionRunJsonLocked(
|
|
459
|
+
runDir,
|
|
460
|
+
(draft) => {
|
|
461
|
+
if (nextGate.status === "approved") consumeSteeringBoundary(draft, "gate", options.boundaryToken);
|
|
462
|
+
draft.gates = normalizeGateMap(draft.gates);
|
|
463
|
+
const prepared = prepareGateDecisionTransition(runDir, gateName, draft.gates[gateName], nextGate, (archive) => answerArchives.push(archive));
|
|
464
|
+
if (nextGate.status === "approved" && draft.mode === "interactive") {
|
|
465
|
+
prepared.handoff_receipt = createApprovalHandoffReceipt(runDir, gateName, prepared, draft);
|
|
466
|
+
}
|
|
467
|
+
delete prepared._handoff_answer_hash;
|
|
468
|
+
draft.gates[gateName] = prepared;
|
|
469
|
+
},
|
|
470
|
+
options,
|
|
471
|
+
{ authorizedGate: gateName, beforeWrite: async () => {
|
|
472
|
+
for (const archive of answerArchives) await archiveConsumedGateAnswer(archive);
|
|
473
|
+
} },
|
|
474
|
+
);
|
|
475
|
+
}, options);
|
|
476
|
+
return { ...result, gate: gateName };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function inspectApprovalHandoffReceipt(runDir, run, gateName) {
|
|
480
|
+
const gate = run?.gates?.[gateName];
|
|
481
|
+
if (!isRecord(gate?.handoff_receipt)) return { ok: false, reason_code: "approval-receipt-missing" };
|
|
482
|
+
const receipt = gate.handoff_receipt;
|
|
483
|
+
try {
|
|
484
|
+
validateApprovalReceiptMaterial(receipt, gateName);
|
|
485
|
+
} catch {
|
|
486
|
+
return { ok: false, reason_code: "approval-receipt-missing" };
|
|
487
|
+
}
|
|
488
|
+
if (!isRecord(gate.pending_snapshot) || receipt.pending_snapshot_hash !== hashValue(gate.pending_snapshot)) {
|
|
489
|
+
return { ok: false, reason_code: "approval-snapshot-mismatch" };
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
const fresh = createPendingGateSnapshot(
|
|
493
|
+
runDir,
|
|
494
|
+
gateName,
|
|
495
|
+
gate.pending_snapshot.artifact_ref,
|
|
496
|
+
gate.pending_snapshot.question_ref,
|
|
497
|
+
gate.pending_snapshot.created_at,
|
|
498
|
+
gate.pending_snapshot.answer_ref,
|
|
499
|
+
);
|
|
500
|
+
// The pending answer was archived on acceptance, so only immutable question
|
|
501
|
+
// and artifact fields are refreshed here.
|
|
502
|
+
for (const field of ["question_ref", "question_hash", "artifact_ref", "artifact_hash", "created_at"]) {
|
|
503
|
+
if (fresh[field] !== gate.pending_snapshot[field]) return { ok: false, reason_code: "approval-snapshot-mismatch" };
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
return { ok: false, reason_code: "approval-snapshot-mismatch" };
|
|
507
|
+
}
|
|
508
|
+
if (stringValue(gate.answer_ref) && receipt.answer_hash !== approvedAnswerHash(runDir, gate)) return { ok: false, reason_code: "approval-snapshot-mismatch" };
|
|
509
|
+
if (receipt.approval_fingerprint !== approvalFingerprint(gateName, gate, receipt)) return { ok: false, reason_code: "approval-snapshot-mismatch" };
|
|
510
|
+
if (receipt.steering_generation !== steeringGeneration(run)) return { ok: false, reason_code: "steering-generation-mismatch" };
|
|
511
|
+
if (!cleanSteeringForHandoff(run.steering)) return { ok: false, reason_code: "steering-state-not-clean" };
|
|
512
|
+
return { ok: true, receipt: cloneJson(receipt) };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function transitionSteeringQueued(runDir, message, options = {}) {
|
|
516
|
+
const text = requireNonEmptyString(message, "steering message");
|
|
517
|
+
return withRunJsonLock(runDir, async () => {
|
|
518
|
+
const current = await readRunJson(runDir);
|
|
519
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
520
|
+
if (TERMINAL_RUN_STATUSES.has(current.status)) throw new Error(`terminal run '${current.status}' cannot be steered`);
|
|
521
|
+
if (current.status !== "running") throw new Error(`steer requires a running run, found '${current.status}'`);
|
|
522
|
+
if (isRecord(current.steering?.pending)) throw new Error("run already has pending steering");
|
|
523
|
+
if (isRecord(current.steering?.uncheckpointed)) throw new Error("run has consumed steering awaiting acknowledgement");
|
|
524
|
+
if (isRecord(current.steering?.action_claim)) throw new Error("run has an action awaiting start acknowledgement");
|
|
525
|
+
if (isRecord(current.steering?.pr_fence)) throw new Error("run has an active pre-PR fence and cannot be steered");
|
|
526
|
+
|
|
527
|
+
const createdAt = timestamp(options.now);
|
|
528
|
+
const id = safeSteeringId(options.id || randomUUID());
|
|
529
|
+
const ref = `steering/pending-${safeTimestamp(createdAt)}-${id}.json`;
|
|
530
|
+
const resolved = resolveSteeringRef(runDir, ref, { mustExist: false });
|
|
531
|
+
await mkdir(dirname(resolved.path), { recursive: true });
|
|
532
|
+
if (existsSync(resolved.path)) throw new Error(`steering ref already exists: ${ref}`);
|
|
533
|
+
const steeringFile = {
|
|
534
|
+
schema_version: 1,
|
|
535
|
+
kind: "operator-steering",
|
|
536
|
+
run_id: current.run_id,
|
|
537
|
+
id,
|
|
538
|
+
message: text,
|
|
539
|
+
message_chars: text.length,
|
|
540
|
+
created_at: createdAt,
|
|
541
|
+
source: "factory steer",
|
|
542
|
+
};
|
|
543
|
+
await writeJsonAtomically(resolved.path, steeringFile);
|
|
544
|
+
const fileHash = hashFile(resolved.path, { mode: "raw" });
|
|
545
|
+
const metadata = { id, ref, hash: fileHash, message_chars: text.length, created_at: createdAt };
|
|
546
|
+
const history = Array.isArray(current.steering?.history) ? cloneJson(current.steering.history) : [];
|
|
547
|
+
history.push({ event: "queued", ...metadata });
|
|
548
|
+
const next = validateRun({
|
|
549
|
+
...cloneJson(current),
|
|
550
|
+
updated_at: createdAt,
|
|
551
|
+
steering: {
|
|
552
|
+
schema_version: 1,
|
|
553
|
+
generation: steeringGeneration(current) + 1,
|
|
554
|
+
pending: metadata,
|
|
555
|
+
uncheckpointed: null,
|
|
556
|
+
boundary: null,
|
|
557
|
+
action_claim: null,
|
|
558
|
+
last_action: cloneJson(current.steering?.last_action ?? null),
|
|
559
|
+
pr_fence: null,
|
|
560
|
+
history,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
564
|
+
return { updated: true, status: next.status, run: next, steering: metadata };
|
|
565
|
+
}, options);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export async function transitionSteeringConsumed(runDir, input, options = {}) {
|
|
569
|
+
const requestedRef = requireNonEmptyString(input?.ref, "steering ref");
|
|
570
|
+
const requestedHash = requireNonEmptyString(input?.hash, "steering hash");
|
|
571
|
+
return withRunJsonLock(runDir, async () => {
|
|
572
|
+
const current = await readRunJson(runDir);
|
|
573
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
574
|
+
if (TERMINAL_RUN_STATUSES.has(current.status)) throw new Error(`terminal run '${current.status}' cannot consume steering`);
|
|
575
|
+
assertNoFreshHeartbeatForSteeringConsume(runDir, options);
|
|
576
|
+
if (isRecord(current.steering?.pr_fence)) throw new Error("steer-consume rejected: active pre-PR fence");
|
|
577
|
+
if (isRecord(current.steering?.action_claim)) throw new Error("steer-consume rejected: action start acknowledgement is pending");
|
|
578
|
+
const uncheckpointed = current.steering?.uncheckpointed;
|
|
579
|
+
if (isRecord(uncheckpointed)) {
|
|
580
|
+
if (uncheckpointed.ref !== requestedRef || uncheckpointed.hash !== requestedHash) throw new Error("uncheckpointed steering ref/hash mismatch");
|
|
581
|
+
const steering = readConsumedSteeringEnvelope(runDir, current, uncheckpointed);
|
|
582
|
+
return { updated: false, reason: "redelivered-uncheckpointed", status: current.status, run: current, steering };
|
|
583
|
+
}
|
|
584
|
+
const pending = current.steering?.pending;
|
|
585
|
+
if (!isRecord(pending)) throw new Error("run has no pending steering");
|
|
586
|
+
if (pending.ref !== requestedRef || pending.hash !== requestedHash) throw new Error("pending steering ref/hash mismatch");
|
|
587
|
+
|
|
588
|
+
const source = resolvePendingSteeringSource(runDir, pending);
|
|
589
|
+
const actualHash = hashFile(source.path, { mode: "raw" });
|
|
590
|
+
if (actualHash !== pending.hash) throw new Error("pending steering file hash mismatch");
|
|
591
|
+
const steeringFile = parseJsonObjectFile(source.path, "pending steering");
|
|
592
|
+
if (steeringFile.kind !== "operator-steering") throw new Error("pending steering kind mismatch");
|
|
593
|
+
if (steeringFile.run_id !== current.run_id) throw new Error("pending steering run_id mismatch");
|
|
594
|
+
if (steeringFile.id !== pending.id) throw new Error("pending steering id mismatch");
|
|
595
|
+
const message = requireNonEmptyString(steeringFile.message, "pending steering message");
|
|
596
|
+
|
|
597
|
+
const consumedAt = timestamp(options.now);
|
|
598
|
+
const consumedRef = source.consumedRef ?? nextConsumedSteeringRef(runDir, pending.id, consumedAt);
|
|
599
|
+
const history = Array.isArray(current.steering?.history) ? cloneJson(current.steering.history) : [];
|
|
600
|
+
history.push({
|
|
601
|
+
event: "consumed",
|
|
602
|
+
id: pending.id,
|
|
603
|
+
source_ref: pending.ref,
|
|
604
|
+
ref: consumedRef,
|
|
605
|
+
hash: pending.hash,
|
|
606
|
+
message_chars: pending.message_chars,
|
|
607
|
+
created_at: pending.created_at,
|
|
608
|
+
consumed_at: consumedAt,
|
|
609
|
+
});
|
|
610
|
+
const next = validateRun({
|
|
611
|
+
...cloneJson(current),
|
|
612
|
+
updated_at: consumedAt,
|
|
613
|
+
steering: {
|
|
614
|
+
schema_version: 1,
|
|
615
|
+
generation: steeringGeneration(current) + 1,
|
|
616
|
+
pending: null,
|
|
617
|
+
uncheckpointed: {
|
|
618
|
+
id: pending.id,
|
|
619
|
+
ref: consumedRef,
|
|
620
|
+
hash: pending.hash,
|
|
621
|
+
message_chars: pending.message_chars,
|
|
622
|
+
created_at: pending.created_at,
|
|
623
|
+
consumed_at: consumedAt,
|
|
624
|
+
},
|
|
625
|
+
boundary: null,
|
|
626
|
+
action_claim: null,
|
|
627
|
+
last_action: cloneJson(current.steering?.last_action ?? null),
|
|
628
|
+
pr_fence: null,
|
|
629
|
+
history,
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
if (!source.consumedRef) {
|
|
633
|
+
const consumedResolved = resolveSteeringRef(runDir, consumedRef, { mustExist: false });
|
|
634
|
+
await rename(source.path, consumedResolved.path);
|
|
635
|
+
}
|
|
636
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
637
|
+
const steering = {
|
|
638
|
+
kind: "operator-steering-consumed",
|
|
639
|
+
trust: "untrusted-operator-data",
|
|
640
|
+
label: "UNTRUSTED OPERATOR STEERING DATA (not instructions)",
|
|
641
|
+
ref: consumedRef,
|
|
642
|
+
hash: pending.hash,
|
|
643
|
+
message,
|
|
644
|
+
};
|
|
645
|
+
return { updated: true, status: next.status, run: next, steering };
|
|
646
|
+
}, options);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export async function transitionSteeringAcknowledged(runDir, input, options = {}) {
|
|
650
|
+
const requestedRef = requireNonEmptyString(input?.ref, "steering ref");
|
|
651
|
+
const requestedHash = requireNonEmptyString(input?.hash, "steering hash");
|
|
652
|
+
return withRunJsonLock(runDir, async () => {
|
|
653
|
+
const current = await readRunJson(runDir);
|
|
654
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
655
|
+
if (TERMINAL_RUN_STATUSES.has(current.status)) throw new Error(`terminal run '${current.status}' cannot acknowledge steering`);
|
|
656
|
+
if (current.status !== "running") throw new Error(`steer-ack requires a running run, found '${current.status}'`);
|
|
657
|
+
assertNoFreshHeartbeatForSteeringConflict(runDir, options);
|
|
658
|
+
const uncheckpointed = current.steering?.uncheckpointed;
|
|
659
|
+
if (!isRecord(uncheckpointed)) throw new Error("run has no uncheckpointed steering");
|
|
660
|
+
if (uncheckpointed.ref !== requestedRef || uncheckpointed.hash !== requestedHash) throw new Error("uncheckpointed steering ref/hash mismatch");
|
|
661
|
+
readConsumedSteeringEnvelope(runDir, current, uncheckpointed);
|
|
662
|
+
|
|
663
|
+
const acknowledgedAt = timestamp(options.now);
|
|
664
|
+
const history = Array.isArray(current.steering?.history) ? cloneJson(current.steering.history) : [];
|
|
665
|
+
history.push({
|
|
666
|
+
event: "acknowledged",
|
|
667
|
+
id: uncheckpointed.id,
|
|
668
|
+
ref: uncheckpointed.ref,
|
|
669
|
+
hash: uncheckpointed.hash,
|
|
670
|
+
message_chars: uncheckpointed.message_chars,
|
|
671
|
+
created_at: uncheckpointed.created_at,
|
|
672
|
+
consumed_at: uncheckpointed.consumed_at,
|
|
673
|
+
acknowledged_at: acknowledgedAt,
|
|
674
|
+
outcome: "applied-prospectively",
|
|
675
|
+
});
|
|
676
|
+
const next = validateRun({
|
|
677
|
+
...cloneJson(current),
|
|
678
|
+
updated_at: acknowledgedAt,
|
|
679
|
+
steering: {
|
|
680
|
+
...cloneJson(current.steering),
|
|
681
|
+
schema_version: 1,
|
|
682
|
+
generation: steeringGeneration(current) + 1,
|
|
683
|
+
pending: null,
|
|
684
|
+
uncheckpointed: null,
|
|
685
|
+
boundary: null,
|
|
686
|
+
action_claim: null,
|
|
687
|
+
pr_fence: null,
|
|
688
|
+
history,
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
692
|
+
return { updated: true, status: next.status, run: next, steering: { ref: requestedRef, hash: requestedHash, acknowledged_at: acknowledgedAt, outcome: "applied-prospectively" } };
|
|
693
|
+
}, options);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export async function transitionSteeringConflict(runDir, input, options = {}) {
|
|
697
|
+
const requestedRef = requireNonEmptyString(input?.ref, "steering ref");
|
|
698
|
+
const requestedHash = requireNonEmptyString(input?.hash, "steering hash");
|
|
699
|
+
return withRunJsonLock(runDir, async () => {
|
|
700
|
+
const current = await readRunJson(runDir);
|
|
701
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
702
|
+
if (TERMINAL_RUN_STATUSES.has(current.status)) throw new Error(`terminal run '${current.status}' cannot record steering conflict`);
|
|
703
|
+
if (current.status !== "running") throw new Error(`steer-conflict requires a running run, found '${current.status}'`);
|
|
704
|
+
assertNoFreshHeartbeatForSteeringConflict(runDir, options);
|
|
705
|
+
|
|
706
|
+
const consumed = current.steering?.uncheckpointed;
|
|
707
|
+
if (!isRecord(consumed)) throw new Error("run has no uncheckpointed steering");
|
|
708
|
+
if (consumed.ref !== requestedRef || consumed.hash !== requestedHash) throw new Error("uncheckpointed steering ref/hash mismatch");
|
|
709
|
+
|
|
710
|
+
const consumedResolved = resolveSteeringRef(runDir, consumed.ref);
|
|
711
|
+
const actualHash = hashFile(consumedResolved.path, { mode: "raw" });
|
|
712
|
+
if (actualHash !== consumed.hash) throw new Error("consumed steering file hash mismatch");
|
|
713
|
+
|
|
714
|
+
const protectedState = collectProtectedSteeringState(runDir, current);
|
|
715
|
+
const terminalResult = buildSteeringConflictTerminalResult(current, { ref: consumed.ref, hash: consumed.hash }, protectedState, input);
|
|
716
|
+
const next = validateRun({
|
|
717
|
+
...cloneJson(current),
|
|
718
|
+
status: "needs-human",
|
|
719
|
+
terminal_result: terminalResult,
|
|
720
|
+
steering: {
|
|
721
|
+
...cloneJson(current.steering),
|
|
722
|
+
generation: steeringGeneration(current) + 1,
|
|
723
|
+
uncheckpointed: null,
|
|
724
|
+
boundary: null,
|
|
725
|
+
action_claim: null,
|
|
726
|
+
pr_fence: null,
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
conflict: true,
|
|
733
|
+
run_id: next.run_id,
|
|
734
|
+
updated: true,
|
|
735
|
+
status: next.status,
|
|
736
|
+
run: next,
|
|
737
|
+
steering: { ref: consumed.ref, hash: consumed.hash },
|
|
738
|
+
protected_state: protectedState,
|
|
739
|
+
terminal_result: next.terminal_result,
|
|
740
|
+
};
|
|
741
|
+
}, options);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export async function transitionSteeringBoundaryOpened(runDir, kind, options = {}) {
|
|
745
|
+
const boundaryKind = normalizeSteeringBoundaryKind(kind);
|
|
746
|
+
return withRunJsonLock(runDir, async () => {
|
|
747
|
+
const current = await readRunJson(runDir);
|
|
748
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
749
|
+
assertBoundaryClean(runDir, current, options, `boundary-open ${boundaryKind}`);
|
|
750
|
+
if (boundaryKind === "terminal" && current.post_pr?.policy?.enabled === true && current.steering?.last_action?.outcome !== "closed") throw new Error("post-PR terminal boundary requires a closed origin action");
|
|
751
|
+
const createdAt = timestamp(options.now);
|
|
752
|
+
const token = safeBoundaryToken(options.token || randomUUID());
|
|
753
|
+
const base = {
|
|
754
|
+
...cloneJson(current),
|
|
755
|
+
updated_at: createdAt,
|
|
756
|
+
steering: normalizedSteeringState(current, { boundary: null }),
|
|
757
|
+
};
|
|
758
|
+
base.steering.boundary = {
|
|
759
|
+
kind: boundaryKind,
|
|
760
|
+
token,
|
|
761
|
+
generation: steeringGeneration(current),
|
|
762
|
+
state_hash: steeringBoundaryStateHash(base),
|
|
763
|
+
created_at: createdAt,
|
|
764
|
+
};
|
|
765
|
+
const next = validateRun(base);
|
|
766
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
767
|
+
return { updated: true, status: next.status, run: next, boundary: cloneJson(next.steering.boundary) };
|
|
768
|
+
}, options);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export async function transitionSteeringBoundaryCrossed(runDir, kind, token, options = {}) {
|
|
772
|
+
const boundaryKind = normalizeSteeringBoundaryKind(kind);
|
|
773
|
+
if (!STEERING_ACTION_KINDS.has(boundaryKind)) throw new Error("boundary-cross supports dispatch, remediation, terminal, post-pr-observe, or post-pr-push");
|
|
774
|
+
return withRunJsonLock(runDir, async () => {
|
|
775
|
+
const current = await readRunJson(runDir);
|
|
776
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
777
|
+
assertBoundaryClean(runDir, current, options, `boundary-cross ${boundaryKind}`);
|
|
778
|
+
const draft = cloneJson(current);
|
|
779
|
+
consumeSteeringBoundary(draft, boundaryKind, token);
|
|
780
|
+
const claimedAt = timestamp(options.now);
|
|
781
|
+
draft.updated_at = claimedAt;
|
|
782
|
+
draft.steering = normalizedSteeringState(draft, {
|
|
783
|
+
boundary: null,
|
|
784
|
+
action_claim: {
|
|
785
|
+
kind: boundaryKind,
|
|
786
|
+
token: safeBoundaryToken(token),
|
|
787
|
+
generation: steeringGeneration(draft),
|
|
788
|
+
claimed_at: claimedAt,
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
const next = validateRun(draft);
|
|
792
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
793
|
+
return { updated: true, status: next.status, run: next, action_claim: cloneJson(next.steering.action_claim) };
|
|
794
|
+
}, options);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export async function transitionSteeringActionStarted(runDir, kind, token, options = {}) {
|
|
798
|
+
return transitionSteeringActionResolved(runDir, kind, token, "started", options);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export async function transitionSteeringActionAborted(runDir, kind, token, options = {}) {
|
|
802
|
+
return transitionSteeringActionResolved(runDir, kind, token, "aborted", options);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export async function transitionSteeringActionClosed(runDir, kind, token, options = {}) {
|
|
806
|
+
const actionKind = normalizeSteeringActionKind(kind);
|
|
807
|
+
return withRunJsonLock(runDir, async () => {
|
|
808
|
+
const current = await readRunJson(runDir);
|
|
809
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
810
|
+
assertSteeringBoundaryClear(current, "action-close");
|
|
811
|
+
if (isRecord(current.steering?.boundary) || isRecord(current.steering?.pr_fence)) throw new Error("action-close requires no boundary or PR fence");
|
|
812
|
+
assertNoFreshHeartbeat(runDir, options, "action-close requires inactive heartbeat");
|
|
813
|
+
const action = current.steering?.last_action;
|
|
814
|
+
const requestedToken = safeBoundaryToken(token);
|
|
815
|
+
if (!isRecord(action) || action.kind !== actionKind || action.token !== requestedToken || action.outcome !== "started" || action.generation !== steeringGeneration(current)) throw new Error("origin action is missing, stale, or not started");
|
|
816
|
+
const closedAt = timestamp(options.now);
|
|
817
|
+
const next = validateRun({ ...cloneJson(current), updated_at: closedAt, steering: normalizedSteeringState(current, { last_action: { ...cloneJson(action), outcome: "closed", resolved_at: closedAt } }) });
|
|
818
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
819
|
+
return { updated: true, status: next.status, run: next, action: cloneJson(next.steering.last_action) };
|
|
820
|
+
}, options);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function transitionSteeringActionResolved(runDir, kind, token, outcome, options = {}) {
|
|
824
|
+
const actionKind = normalizeSteeringActionKind(kind);
|
|
825
|
+
return withRunJsonLock(runDir, async () => {
|
|
826
|
+
const current = await readRunJson(runDir);
|
|
827
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
828
|
+
const claim = assertSteeringActionClaim(current, actionKind, token);
|
|
829
|
+
if (outcome === "aborted") {
|
|
830
|
+
const recoverable = inspectRecoverableHeartbeat(runDir, options);
|
|
831
|
+
if (!recoverable.ok) throw new Error("action-abort requires inactive heartbeat: active-heartbeat");
|
|
832
|
+
await stopHeartbeatForRecovery(runDir, recoverable.heartbeat, timestamp(options.now));
|
|
833
|
+
}
|
|
834
|
+
const resolvedAt = timestamp(options.now);
|
|
835
|
+
const next = validateRun({
|
|
836
|
+
...cloneJson(current),
|
|
837
|
+
updated_at: resolvedAt,
|
|
838
|
+
steering: normalizedSteeringState(current, {
|
|
839
|
+
action_claim: null,
|
|
840
|
+
last_action: {
|
|
841
|
+
kind: actionKind,
|
|
842
|
+
token: claim.token,
|
|
843
|
+
generation: claim.generation,
|
|
844
|
+
outcome,
|
|
845
|
+
claimed_at: claim.claimed_at,
|
|
846
|
+
resolved_at: resolvedAt,
|
|
847
|
+
},
|
|
848
|
+
}),
|
|
849
|
+
});
|
|
850
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
851
|
+
return { updated: true, status: next.status, run: next, action: cloneJson(next.steering.last_action) };
|
|
852
|
+
}, options);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export async function transitionPrePrFenceEstablished(runDir, options = {}) {
|
|
856
|
+
return withRunJsonLock(runDir, async () => {
|
|
857
|
+
const current = await readRunJson(runDir);
|
|
858
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
859
|
+
assertBoundaryClean(runDir, current, options, "pr-fence");
|
|
860
|
+
assertPrCreatedReadiness(runDir, current);
|
|
861
|
+
const createdAt = timestamp(options.now);
|
|
862
|
+
const token = safeBoundaryToken(options.token || randomUUID());
|
|
863
|
+
const base = {
|
|
864
|
+
...cloneJson(current),
|
|
865
|
+
updated_at: createdAt,
|
|
866
|
+
steering: normalizedSteeringState(current, { boundary: null, pr_fence: null }),
|
|
867
|
+
};
|
|
868
|
+
base.steering.pr_fence = {
|
|
869
|
+
token,
|
|
870
|
+
generation: steeringGeneration(current),
|
|
871
|
+
state_hash: steeringBoundaryStateHash(base),
|
|
872
|
+
created_at: createdAt,
|
|
873
|
+
};
|
|
874
|
+
const next = validateRun(base);
|
|
875
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
876
|
+
return { updated: true, status: next.status, run: next, fence: cloneJson(next.steering.pr_fence) };
|
|
877
|
+
}, options);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
export async function transitionPrePrFenceCleared(runDir, token, options = {}) {
|
|
881
|
+
return withRunJsonLock(runDir, async () => {
|
|
882
|
+
const current = await readRunJson(runDir);
|
|
883
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
884
|
+
assertPrFenceToken(current, token);
|
|
885
|
+
const next = validateRun({
|
|
886
|
+
...cloneJson(current),
|
|
887
|
+
updated_at: timestamp(options.now),
|
|
888
|
+
steering: normalizedSteeringState(current, { pr_fence: null }),
|
|
889
|
+
});
|
|
890
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
891
|
+
return { updated: true, status: next.status, run: next, fence: null };
|
|
892
|
+
}, options);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export function resolveGateAnswerTarget(runDir, gateName, gate) {
|
|
896
|
+
if (!stringValue(gateName)) throw new Error("resolveGateAnswerTarget requires a gate name");
|
|
897
|
+
assertSafeGateName(gateName);
|
|
898
|
+
if (!isRecord(gate) || gate.status !== "pending") throw new Error(`gate '${gateName}' is not pending`);
|
|
899
|
+
if (!isRecord(gate.pending_snapshot)) throw new Error(`gate '${gateName}' requires pending_snapshot before external answers`);
|
|
900
|
+
if (!stringValue(gate.answer_ref)) throw new Error(`gate '${gateName}' requires answer_ref`);
|
|
901
|
+
if (!stringValue(gate.artifact)) throw new Error(`gate '${gateName}' requires artifact ref`);
|
|
902
|
+
if (!stringValue(gate.question_ref)) throw new Error(`gate '${gateName}' requires question_ref`);
|
|
903
|
+
const freshSnapshot = createPendingGateSnapshot(runDir, gateName, gate.artifact, gate.question_ref, gate.pending_snapshot.created_at, gate.answer_ref);
|
|
904
|
+
assertPendingSnapshotMatches(gateName, gate.pending_snapshot, freshSnapshot, "current pending snapshot");
|
|
905
|
+
const answer = resolveGateRef(runDir, gate.answer_ref, { mustExist: false });
|
|
906
|
+
return { gatesDir: dirname(answer.path), answerPath: answer.path };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export async function transitionPrCreated(runDir, input, options = {}) {
|
|
910
|
+
const request = { ...normalizePrCreatedInput(input), runDir };
|
|
911
|
+
const result = await withRunJsonLock(runDir, async () => transitionRunJsonLocked(
|
|
912
|
+
runDir,
|
|
913
|
+
(draft) => {
|
|
914
|
+
assertSteeringBoundaryClear(draft, "pr-created");
|
|
915
|
+
assertPrFence(draft, options.fenceToken);
|
|
916
|
+
assertPrCreatedPreconditions(draft, request);
|
|
917
|
+
draft.pr_url = request.pr_url;
|
|
918
|
+
if (draft.post_pr?.policy?.enabled === true) {
|
|
919
|
+
draft.status = "running";
|
|
920
|
+
draft.terminal_result = null;
|
|
921
|
+
draft.post_pr = initializePostPrObservation(draft.post_pr, request, options.now);
|
|
922
|
+
} else {
|
|
923
|
+
draft.status = "completed";
|
|
924
|
+
draft.terminal_result = normalizePrCreatedTerminalResult(draft, request);
|
|
925
|
+
}
|
|
926
|
+
draft.steering = normalizedSteeringState(draft, { boundary: null, pr_fence: null });
|
|
927
|
+
},
|
|
928
|
+
options,
|
|
929
|
+
{ prCreated: true },
|
|
930
|
+
), options);
|
|
931
|
+
return { ...result, pr_url: result.run.pr_url, terminal_result: result.run.terminal_result };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** Checked replacement used by the orchestration layer after external work is inactive. */
|
|
935
|
+
export async function transitionPostPrState(runDir, postPr, options = {}) {
|
|
936
|
+
if (!isRecord(postPr)) throw new Error("transitionPostPrState requires post_pr state");
|
|
937
|
+
return withRunJsonLock(runDir, async () => transitionRunJsonLocked(runDir, (draft, { current }) => {
|
|
938
|
+
assertPostPrMutationReady(runDir, current, options, "post-PR transition");
|
|
939
|
+
const nextPostPr = cloneJson(postPr);
|
|
940
|
+
if (sameJson(current.post_pr, nextPostPr)) return;
|
|
941
|
+
assertPostPrPhaseTransition(current.post_pr, nextPostPr);
|
|
942
|
+
assertPostPrAttemptTransition(current, nextPostPr);
|
|
943
|
+
assertPostPrMonotonicState(current.post_pr, nextPostPr);
|
|
944
|
+
assertPostPrCandidateGitState(current, nextPostPr, options);
|
|
945
|
+
draft.post_pr = nextPostPr;
|
|
946
|
+
draft.updated_at = timestamp(options.now);
|
|
947
|
+
}, options, { postPr: true, beforeWrite: (next) => assertPostPrRefsConsistent(runDir, next) }), options);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Reserve exactly one routable check-red attempt. Matching replay is a no-op. */
|
|
951
|
+
export async function transitionPostPrFailure(runDir, input, options = {}) {
|
|
952
|
+
if (!isRecord(input?.remediation)) throw new Error("post-PR failure requires remediation state");
|
|
953
|
+
return withRunJsonLock(runDir, async () => transitionRunJsonLocked(runDir, (draft, { current }) => {
|
|
954
|
+
assertPostPrMutationReady(runDir, current, options, "post-PR failure");
|
|
955
|
+
const remediation = cloneJson(input.remediation);
|
|
956
|
+
const replay = current.post_pr.remediation;
|
|
957
|
+
if (isRecord(replay) && replay.attempt === remediation.attempt) {
|
|
958
|
+
assertPostPrFailureEvidence(runDir, current, remediation);
|
|
959
|
+
if (sameJson(replay, remediation)) {
|
|
960
|
+
assertPostPrFailureReplayContext(current, remediation);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
throw new Error("conflicting post-PR failure replay");
|
|
964
|
+
}
|
|
965
|
+
assertPostPrFailureSource(current, remediation);
|
|
966
|
+
assertPostPrFailureEvidence(runDir, current, remediation);
|
|
967
|
+
const expectedAttempt = current.post_pr.attempt + 1;
|
|
968
|
+
if (remediation.attempt !== expectedAttempt) throw new Error(`post-PR attempt must advance exactly from ${current.post_pr.attempt} to ${expectedAttempt}`);
|
|
969
|
+
if (expectedAttempt > (Number.isInteger(current.max_retries) ? current.max_retries : 3)) throw new Error(`post-PR attempt ${expectedAttempt} exceeds max_retries`);
|
|
970
|
+
const evidenceRefs = Array.isArray(current.post_pr.evidence_refs) ? cloneJson(current.post_pr.evidence_refs) : [];
|
|
971
|
+
const failureBinding = { ref: remediation.failure_evidence_ref, hash: remediation.failure_evidence_hash };
|
|
972
|
+
if (!evidenceRefs.some((item) => sameJson(item, failureBinding))) evidenceRefs.push(failureBinding);
|
|
973
|
+
draft.post_pr = { ...cloneJson(current.post_pr), phase: "failure-recording", attempt: expectedAttempt, remediation, evidence_refs: evidenceRefs };
|
|
974
|
+
draft.updated_at = timestamp(options.now);
|
|
975
|
+
}, options, { postPr: true, beforeWrite: (next) => assertPostPrRefsConsistent(runDir, next) }), options);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export async function transitionPostPrTerminal(runDir, input, options = {}) {
|
|
979
|
+
if (!isRecord(input)) throw new Error("transitionPostPrTerminal requires an input object");
|
|
980
|
+
const status = requireNonEmptyString(input.status, "post-PR terminal status");
|
|
981
|
+
const reason = requireNonEmptyString(input.reason, "post-PR terminal reason");
|
|
982
|
+
if (!POST_PR_TERMINAL_PHASE[status] || !POST_PR_TERMINAL_REASONS[status]?.includes(reason)) throw new Error(`invalid closed post-PR terminal reason '${reason}' for ${status}`);
|
|
983
|
+
return withRunJsonLock(runDir, async () => transitionRunJsonLocked(runDir, (draft, { current }) => {
|
|
984
|
+
assertPostPrMutationReady(runDir, current, options, "post-PR terminal transition");
|
|
985
|
+
const phase = POST_PR_TERMINAL_PHASE[status];
|
|
986
|
+
if (current.post_pr?.phase === phase && current.status === status && current.terminal_result?.reason === reason) return;
|
|
987
|
+
if (!current.post_pr?.policy?.enabled) throw new Error("post-PR terminal transition requires enabled persisted policy");
|
|
988
|
+
assertPostPrTerminalPreconditions(current, status, reason, input, options);
|
|
989
|
+
assertPostPrPhaseTransition(current.post_pr, { ...current.post_pr, phase });
|
|
990
|
+
draft.status = status;
|
|
991
|
+
draft.post_pr = { ...cloneJson(current.post_pr), phase, terminal_fact: normalizedPostPrTerminalFact(reason, input.trigger_fact) };
|
|
992
|
+
if (reason === "post-pr-retry-exhausted") draft.post_pr.continuation_review = bindPostPrContinuationReview(runDir, current, input.continuation_review);
|
|
993
|
+
draft.terminal_result = {
|
|
994
|
+
status,
|
|
995
|
+
run_id: current.run_id,
|
|
996
|
+
pr_url: current.pr_url,
|
|
997
|
+
reason,
|
|
998
|
+
summary: stringValue(input.summary) ? input.summary : reason,
|
|
999
|
+
artifacts: isRecord(input.artifacts) ? cloneJson(input.artifacts) : {},
|
|
1000
|
+
};
|
|
1001
|
+
validateRun(draft);
|
|
1002
|
+
const terminalAction = current.steering?.last_action;
|
|
1003
|
+
if (!stringValue(options.terminalActionToken) || !Number.isInteger(options.terminalActionGeneration)
|
|
1004
|
+
|| terminalAction?.kind !== "terminal" || terminalAction?.token !== options.terminalActionToken || terminalAction?.generation !== options.terminalActionGeneration || terminalAction?.outcome !== "started") {
|
|
1005
|
+
throw new Error("post-PR terminal transition requires the exact fresh started terminal action");
|
|
1006
|
+
}
|
|
1007
|
+
draft.updated_at = timestamp(options.now);
|
|
1008
|
+
}, options, { postPr: true, postPrTerminal: true, beforeWrite: (next) => assertPostPrRefsConsistent(runDir, next) }), options);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export async function transitionTerminalResult(runDir, terminalResult, options = {}) {
|
|
1012
|
+
const nextTerminalResult = normalizeTerminalResult(terminalResult);
|
|
1013
|
+
const result = await withRunJsonLock(runDir, async () => transitionRunJsonLocked(runDir, (draft) => {
|
|
1014
|
+
assertSteeringBoundaryClear(draft, "terminal");
|
|
1015
|
+
consumeSteeringBoundary(draft, "terminal", options.boundaryToken);
|
|
1016
|
+
const next = { ...cloneJson(nextTerminalResult), run_id: draft.run_id, status: nextTerminalResult.status };
|
|
1017
|
+
draft.status = next.status;
|
|
1018
|
+
draft.terminal_result = next;
|
|
1019
|
+
}, options, { terminal: true }), options);
|
|
1020
|
+
return { ...result, terminal_result: result.run.terminal_result };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export async function transitionCostUsage(runDir, input, options = {}) {
|
|
1024
|
+
const result = await transitionRunJson(runDir, (draft, { current }) => {
|
|
1025
|
+
if (TERMINAL_RUN_STATUSES.has(current.status)) throw new Error(`terminal run '${current.status}' cannot be mutated`);
|
|
1026
|
+
assertNoFreshHeartbeatForCostRecord(runDir, options);
|
|
1027
|
+
draft.cost_attribution = appendCostAttributionEntry(draft.cost_attribution, input, {
|
|
1028
|
+
runId: draft.run_id,
|
|
1029
|
+
now: options.now,
|
|
1030
|
+
id: options.id,
|
|
1031
|
+
});
|
|
1032
|
+
draft.updated_at = draft.cost_attribution.updated_at;
|
|
1033
|
+
}, options);
|
|
1034
|
+
return { ...result, cost_attribution: result.run.cost_attribution };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
export async function transitionRecoverOrphan(runDir, reason = "orphaned factory run", options = {}) {
|
|
1038
|
+
return withRunJsonLock(runDir, async () => {
|
|
1039
|
+
const current = await readRunJson(runDir);
|
|
1040
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
1041
|
+
if (current.status !== "running") throw new Error(`recover requires a running run, found '${current.status}'`);
|
|
1042
|
+
assertSteeringBoundaryClear(current, "recover");
|
|
1043
|
+
if (isRecord(current.steering?.pr_fence)) throw new Error("recover rejected: active pre-PR fence");
|
|
1044
|
+
const recoverable = inspectRecoverableHeartbeat(runDir, options);
|
|
1045
|
+
if (!recoverable.ok) throw new Error(`recover requires terminal, missing, stale, or dead heartbeat: ${recoverable.reason}`);
|
|
1046
|
+
|
|
1047
|
+
const now = timestamp(options.now);
|
|
1048
|
+
await stopHeartbeatForRecovery(runDir, recoverable.heartbeat, now);
|
|
1049
|
+
const next = validateRun({
|
|
1050
|
+
...current,
|
|
1051
|
+
status: "needs-human",
|
|
1052
|
+
updated_at: now,
|
|
1053
|
+
terminal_result: {
|
|
1054
|
+
status: "needs-human",
|
|
1055
|
+
run_id: current.run_id,
|
|
1056
|
+
pr_url: current.pr_url || null,
|
|
1057
|
+
reason: stringValue(reason) ? String(reason) : "orphaned factory run",
|
|
1058
|
+
summary: "Run was recovered from an orphaned or stale heartbeat and requires human inspection before resuming.",
|
|
1059
|
+
artifacts: {},
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
1064
|
+
return { updated: true, status: next.status, run: next, recovery: recoverable };
|
|
1065
|
+
}, options);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
export async function transitionRunStep(runDir, stepSelector, updater, options = {}) {
|
|
1069
|
+
assertCollectionUpdater(updater, "transitionRunStep");
|
|
1070
|
+
let stepIndex = -1;
|
|
1071
|
+
const result = await transitionRunJson(runDir, async (draft) => {
|
|
1072
|
+
const hadSteps = Array.isArray(draft.steps);
|
|
1073
|
+
const steps = hadSteps ? draft.steps : [];
|
|
1074
|
+
if (options.mustExist && !collectionHasItem(steps, stepSelector, "agent")) throw new Error(`step '${formatSelector(stepSelector)}' not found`);
|
|
1075
|
+
const priorIndex = selectCollectionItemIndex(steps, stepSelector, "step selector", "agent");
|
|
1076
|
+
const priorStep = priorIndex >= 0 ? cloneJson(steps[priorIndex]) : null;
|
|
1077
|
+
const update = await applyCollectionItemUpdate({ items: steps, selector: stepSelector, updater, selectorLabel: "step selector", seed: seedRunStep(stepSelector), identityKey: "agent" });
|
|
1078
|
+
stepIndex = update.index;
|
|
1079
|
+
if (!update.changed) return;
|
|
1080
|
+
if (!hadSteps) draft.steps = steps;
|
|
1081
|
+
if (stepIndex >= 0) {
|
|
1082
|
+
assertTestVerifierIntegrationGate(draft, steps[stepIndex], priorStep);
|
|
1083
|
+
bindStepAcceptance(runDir, steps[stepIndex]);
|
|
1084
|
+
}
|
|
1085
|
+
}, options);
|
|
1086
|
+
return { ...result, step_index: stepIndex, step: stepIndex >= 0 ? result.run.steps?.[stepIndex] ?? null : null };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function assertTestVerifierIntegrationGate(run, step, priorStep) {
|
|
1090
|
+
if (step?.agent !== "test-verifier" || step.status !== "running") return;
|
|
1091
|
+
const incomplete = Array.isArray(run.slices) ? run.slices.filter((slice) => slice?.status !== "merged") : [];
|
|
1092
|
+
if (incomplete.length > 0) {
|
|
1093
|
+
throw new Error(`test-verifier integration gate requires all slices merged: ${incomplete.map((slice) => slice?.id || "unknown").join(", ")}`);
|
|
1094
|
+
}
|
|
1095
|
+
if (!Number.isInteger(step.attempts) || step.attempts < 1) {
|
|
1096
|
+
throw new Error("test-verifier integration gate requires a positive attempt number");
|
|
1097
|
+
}
|
|
1098
|
+
const maxAttempts = Number.isInteger(run.max_retries) ? run.max_retries : 3;
|
|
1099
|
+
if (step.attempts > maxAttempts) {
|
|
1100
|
+
throw new Error(`test-verifier integration gate attempt ${step.attempts} exceeds max_retries ${maxAttempts}`);
|
|
1101
|
+
}
|
|
1102
|
+
const priorAttempts = Number.isInteger(priorStep?.attempts) ? priorStep.attempts : 0;
|
|
1103
|
+
const expectedAttempts = priorStep?.status === "running" ? priorAttempts : priorAttempts + 1;
|
|
1104
|
+
if (step.attempts !== expectedAttempts) {
|
|
1105
|
+
throw new Error(`test-verifier integration gate must advance from attempt ${priorAttempts} to ${expectedAttempts}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Bind the exact accepted bytes to the step at the acceptance transition, so a
|
|
1110
|
+
// later blocked-run continuation can prove the artifact/review it reuses are the
|
|
1111
|
+
// ones that were accepted — not whatever the mutable files happen to contain when
|
|
1112
|
+
// the continuation is built. Best-effort: only binds refs that currently resolve
|
|
1113
|
+
// (present, in-run, non-symlink). An accepted step whose artifact is absent stays
|
|
1114
|
+
// unbound, and any continuation reuse of it fails closed.
|
|
1115
|
+
//
|
|
1116
|
+
// Any transition that does not successfully bind the CURRENT accepted artifact/
|
|
1117
|
+
// review must not leave a prior binding behind — otherwise accepted(A) → rejected
|
|
1118
|
+
// → accepted(missing B) would keep A's binding while the step points at B, a stale
|
|
1119
|
+
// provenance claim. So clear first, then re-bind only when this transition's own
|
|
1120
|
+
// accepted refs resolve.
|
|
1121
|
+
function bindStepAcceptance(runDir, step) {
|
|
1122
|
+
if (!step) return;
|
|
1123
|
+
delete step.acceptance;
|
|
1124
|
+
if (step.status !== "accepted") return;
|
|
1125
|
+
const artifactRef = typeof step.artifact_ref === "string" ? step.artifact_ref.trim() : "";
|
|
1126
|
+
if (!artifactRef) return;
|
|
1127
|
+
const artifactHash = tryHashDurableRef(() => resolveArtifactRef(runDir, artifactRef, { mustExist: true }));
|
|
1128
|
+
if (!artifactHash) return;
|
|
1129
|
+
const acceptance = { artifact_ref: artifactRef, artifact_hash: artifactHash };
|
|
1130
|
+
const reviewRef = typeof step.review_ref === "string" ? step.review_ref.trim() : "";
|
|
1131
|
+
if (reviewRef) {
|
|
1132
|
+
const reviewHash = tryHashDurableRef(() => resolveReviewRef(runDir, reviewRef, { mustExist: true }));
|
|
1133
|
+
if (reviewHash) {
|
|
1134
|
+
acceptance.review_ref = reviewRef;
|
|
1135
|
+
acceptance.review_hash = reviewHash;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
step.acceptance = acceptance;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function tryHashDurableRef(resolveFn) {
|
|
1142
|
+
try {
|
|
1143
|
+
return hashFile(resolveFn().path);
|
|
1144
|
+
} catch {
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
export async function transitionRunSlice(runDir, sliceId, updater, options = {}) {
|
|
1150
|
+
assertCollectionUpdater(updater, "transitionRunSlice");
|
|
1151
|
+
let sliceIndex = -1;
|
|
1152
|
+
const result = await transitionRunJson(runDir, async (draft) => {
|
|
1153
|
+
const hadSlices = Array.isArray(draft.slices);
|
|
1154
|
+
const slices = hadSlices ? draft.slices : [];
|
|
1155
|
+
if (options.mustExist && !collectionHasItem(slices, sliceId, "id")) throw new Error(`slice '${formatSelector(sliceId)}' not found`);
|
|
1156
|
+
// `merged` is a one-way state owned by transitionSliceMerged. Reject any
|
|
1157
|
+
// generic mutation of an already-merged slice so its durable merged state
|
|
1158
|
+
// (merge_commit, review_ref) cannot be rolled back to running/review/blocked
|
|
1159
|
+
// through the public slice-status path.
|
|
1160
|
+
const priorIndex = selectCollectionItemIndex(slices, sliceId, "slice selector", "id");
|
|
1161
|
+
if (priorIndex >= 0 && slices[priorIndex]?.status === "merged") {
|
|
1162
|
+
throw new Error(`slice '${slices[priorIndex].id || formatSelector(sliceId)}' is already merged; merged slices are immutable via transitionRunSlice`);
|
|
1163
|
+
}
|
|
1164
|
+
const update = await applyCollectionItemUpdate({ items: slices, selector: sliceId, updater, selectorLabel: "slice selector", seed: seedRunSlice(sliceId), identityKey: "id" });
|
|
1165
|
+
sliceIndex = update.index;
|
|
1166
|
+
if (!update.changed) return;
|
|
1167
|
+
if (!hadSlices) draft.slices = slices;
|
|
1168
|
+
if (sliceIndex >= 0 && slices[sliceIndex].status === "merged") {
|
|
1169
|
+
throw new Error(`slice '${slices[sliceIndex].id || formatSelector(sliceId)}' merges must use transitionSliceMerged`);
|
|
1170
|
+
}
|
|
1171
|
+
if (sliceIndex >= 0) assertSliceReviewPreconditions(runDir, slices[sliceIndex].id || sliceId, slices[sliceIndex]);
|
|
1172
|
+
}, options);
|
|
1173
|
+
return { ...result, slice_index: sliceIndex, slice: sliceIndex >= 0 ? result.run.slices?.[sliceIndex] ?? null : null };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export async function transitionSliceMerged(runDir, sliceId, input = {}, options = {}) {
|
|
1177
|
+
if (!stringValue(sliceId)) throw new Error("transitionSliceMerged requires a slice id");
|
|
1178
|
+
const request = normalizeSliceMergedInput(input);
|
|
1179
|
+
let sliceIndex = -1;
|
|
1180
|
+
const result = await transitionRunJson(runDir, (draft) => {
|
|
1181
|
+
const slices = Array.isArray(draft.slices) ? draft.slices : [];
|
|
1182
|
+
sliceIndex = slices.findIndex((slice) => slice?.id === sliceId);
|
|
1183
|
+
if (sliceIndex < 0) throw new Error(`slice '${sliceId}' not found`);
|
|
1184
|
+
const currentSlice = slices[sliceIndex];
|
|
1185
|
+
if (currentSlice.status === "merged") throw new Error(`slice '${sliceId}' is already merged`);
|
|
1186
|
+
const updatedAt = timestamp(options.now);
|
|
1187
|
+
const nextSlice = { ...currentSlice, merge_commit: request.merge_commit };
|
|
1188
|
+
assertSliceMergedPreconditions(runDir, sliceId, nextSlice, options);
|
|
1189
|
+
slices[sliceIndex] = {
|
|
1190
|
+
...nextSlice,
|
|
1191
|
+
status: "merged",
|
|
1192
|
+
merge_commit: request.merge_commit,
|
|
1193
|
+
updated_at: updatedAt,
|
|
1194
|
+
};
|
|
1195
|
+
draft.slices = slices;
|
|
1196
|
+
draft.updated_at = updatedAt;
|
|
1197
|
+
}, options);
|
|
1198
|
+
return { ...result, slice_index: sliceIndex, slice: sliceIndex >= 0 ? result.run.slices?.[sliceIndex] ?? null : null };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
export async function transitionLifecycleRun(runDir, mutator, options = {}) {
|
|
1202
|
+
return transitionRunJson(runDir, mutator, options);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
export async function mutateRunJsonLocked(runDir, mutator, options = {}) {
|
|
1206
|
+
if (typeof mutator !== "function") throw new Error("mutateRunJsonLocked requires a mutator");
|
|
1207
|
+
return transitionRunJson(runDir, mutator, options);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
export async function heartbeatOnce(runDir, { now } = {}, options = {}) {
|
|
1211
|
+
const heartbeatAt = timestamp(now);
|
|
1212
|
+
|
|
1213
|
+
return withRunJsonLock(runDir, async () => {
|
|
1214
|
+
const current = await readRunJson(runDir);
|
|
1215
|
+
if (isRecord(current.steering?.pr_fence)) throw new Error("heartbeat tick rejected: active pre-PR fence");
|
|
1216
|
+
if (TERMINAL_RUN_STATUSES.has(current.status)) return { updated: false, reason: "terminal-status", status: current.status, run: current };
|
|
1217
|
+
if (current.status !== "running") return { updated: false, reason: "run-not-running", status: current.status, run: current };
|
|
1218
|
+
const protectedGate = pendingProtectedGate(current);
|
|
1219
|
+
if (protectedGate) return { updated: false, reason: "protected-gate-pending", gate: protectedGate, status: current.status, run: current };
|
|
1220
|
+
if (!hasInFlightHeartbeatWork(current)) return { updated: false, reason: "no-in-flight-work", status: current.status, run: current };
|
|
1221
|
+
|
|
1222
|
+
const next = validateRun({ ...current, heartbeat_at: heartbeatAt });
|
|
1223
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
1224
|
+
return { updated: true, status: next.status, heartbeat_at: heartbeatAt, run: next };
|
|
1225
|
+
}, options);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
export function hasInFlightHeartbeatWork(run) {
|
|
1229
|
+
if (Array.isArray(run.steps) && run.steps.some((step) => HEARTBEAT_STEP_IN_FLIGHT_STATUSES.has(step?.status))) return true;
|
|
1230
|
+
if (Array.isArray(run.slices) && run.slices.some((slice) => HEARTBEAT_SLICE_IN_FLIGHT_STATUSES.has(slice?.status))) return true;
|
|
1231
|
+
if (run?.status === "running" && run?.post_pr?.policy?.enabled === true && POST_PR_HEARTBEAT_PHASES.has(run.post_pr.phase)) return true;
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function transitionRunJsonLocked(runDir, mutator, options = {}, hooks = {}) {
|
|
1236
|
+
const current = await readRunJson(runDir);
|
|
1237
|
+
assertExpectedCurrentHash(current, options.expectedCurrentHash);
|
|
1238
|
+
assertRunJsonWriterAllowed(current, "run.json transition", { allowPrePrFence: hooks.prCreated === true });
|
|
1239
|
+
|
|
1240
|
+
const draft = cloneJson(current);
|
|
1241
|
+
let nextValue = await mutator(draft, { current, runDir });
|
|
1242
|
+
if (nextValue === undefined) {
|
|
1243
|
+
if (sameJson(current, draft)) return { updated: false, reason: "mutator-skip", status: current.status, run: current };
|
|
1244
|
+
nextValue = draft;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
assertPostPrGenericMutation(current, nextValue, hooks);
|
|
1248
|
+
const next = validateRun(nextValue);
|
|
1249
|
+
assertGateDecisionTransitions(current, next, hooks);
|
|
1250
|
+
assertTerminalTransition(current, next, hooks);
|
|
1251
|
+
if (typeof hooks.beforeWrite === "function") await hooks.beforeWrite(next, current);
|
|
1252
|
+
await writeJsonAtomically(join(runDir, RUN_FILE), next);
|
|
1253
|
+
return { updated: true, status: next.status, run: next };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async function readRunJson(runDir) {
|
|
1257
|
+
return validateRun(await readJson(join(runDir, RUN_FILE)));
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function inspectRecoverableHeartbeat(runDir, options = {}) {
|
|
1261
|
+
const heartbeatPath = join(runDir, HEARTBEAT_FILE);
|
|
1262
|
+
if (!existsSync(heartbeatPath)) return { ok: true, reason: "missing-heartbeat", heartbeat: null };
|
|
1263
|
+
let heartbeat;
|
|
1264
|
+
try {
|
|
1265
|
+
heartbeat = validateHeartbeatState(JSON.parse(readFileSync(heartbeatPath, "utf8")));
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
return { ok: true, reason: `invalid-heartbeat:${error.message}`, heartbeat: null };
|
|
1268
|
+
}
|
|
1269
|
+
const liveness = inspectHeartbeatLiveness(heartbeat, options);
|
|
1270
|
+
if (liveness.status === "absent") return { ok: true, reason: liveness.reason, heartbeat };
|
|
1271
|
+
return { ok: false, reason: liveness.reason, heartbeat };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function assertNoFreshHeartbeatForSteeringConsume(runDir, options = {}) {
|
|
1275
|
+
assertNoFreshHeartbeat(runDir, options, "steer-consume requires resumable run");
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function assertNoFreshHeartbeatForSteeringConflict(runDir, options = {}) {
|
|
1279
|
+
assertNoFreshHeartbeat(runDir, options, "steer-conflict requires inactive heartbeat");
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function assertNoFreshHeartbeatForCostRecord(runDir, options = {}) {
|
|
1283
|
+
assertNoFreshHeartbeat(runDir, options, "cost-record requires inactive heartbeat");
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function assertNoFreshHeartbeat(runDir, options = {}, prefix) {
|
|
1287
|
+
const heartbeatPath = join(runDir, HEARTBEAT_FILE);
|
|
1288
|
+
if (!existsSync(heartbeatPath)) return;
|
|
1289
|
+
let heartbeat;
|
|
1290
|
+
try {
|
|
1291
|
+
heartbeat = validateHeartbeatState(JSON.parse(readFileSync(heartbeatPath, "utf8")));
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
throw new Error(`${prefix}: invalid-run-state (${error.message})`);
|
|
1294
|
+
}
|
|
1295
|
+
const liveness = inspectHeartbeatLiveness(heartbeat, options);
|
|
1296
|
+
// A live PID is not by itself proof that a long-wait heartbeat is still
|
|
1297
|
+
// active. Once its tick evidence is stale, the lock winner may establish a
|
|
1298
|
+
// fence; a queued heartbeat tick will then observe that fence and stop.
|
|
1299
|
+
// Other ambiguous evidence remains fail-closed.
|
|
1300
|
+
if (liveness.status === "absent" || liveness.reason === "stale-heartbeat") return;
|
|
1301
|
+
throw new Error(`${prefix}: active-heartbeat`);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function stopHeartbeatForRecovery(runDir, heartbeat, now) {
|
|
1305
|
+
if (!heartbeat) return;
|
|
1306
|
+
const next = validateHeartbeatState({
|
|
1307
|
+
...heartbeat,
|
|
1308
|
+
pid: null,
|
|
1309
|
+
last_tick_at: now,
|
|
1310
|
+
});
|
|
1311
|
+
await writeJsonAtomically(join(runDir, HEARTBEAT_FILE), next);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function inspectHeartbeatLiveness(heartbeat, options = {}) {
|
|
1315
|
+
const nowMs = Date.parse(timestamp(options.now));
|
|
1316
|
+
const lastTickMs = Date.parse(heartbeat.last_tick_at || "");
|
|
1317
|
+
const intervalMs = Number.isInteger(heartbeat.interval_ms) && heartbeat.interval_ms > 0 ? heartbeat.interval_ms : DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
1318
|
+
const staleMs = Math.max(2 * intervalMs, MIN_STALE_HEARTBEAT_MS);
|
|
1319
|
+
const status = inspectProcessLiveness(heartbeat.pid, options);
|
|
1320
|
+
if (status === "absent") return { status, fresh: false, reason: "dead-heartbeat-process" };
|
|
1321
|
+
if (status === "indeterminate") return { status, fresh: false, reason: "indeterminate-heartbeat-process" };
|
|
1322
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(lastTickMs)) return { status, fresh: false, reason: "invalid-heartbeat-time" };
|
|
1323
|
+
if (nowMs - lastTickMs > staleMs) return { status, fresh: false, reason: "stale-heartbeat" };
|
|
1324
|
+
return { status, fresh: true, reason: "fresh-heartbeat" };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function inspectProcessLiveness(pid, options = {}) {
|
|
1328
|
+
if (typeof options.processAliveFn === "function") return probeLegacyBooleanLiveness(options.processAliveFn, pid);
|
|
1329
|
+
if (!Number.isInteger(pid) || pid <= 0) return "absent";
|
|
1330
|
+
try {
|
|
1331
|
+
process.kill(pid, 0);
|
|
1332
|
+
return "live";
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
if (error?.code === "ESRCH") return "absent";
|
|
1335
|
+
return "indeterminate";
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function assertExpectedCurrentHash(run, expectedCurrentHash) {
|
|
1340
|
+
if (expectedCurrentHash === undefined || expectedCurrentHash === null) return;
|
|
1341
|
+
if (!stringValue(expectedCurrentHash)) throw new Error("expectedCurrentHash must be a non-empty string");
|
|
1342
|
+
const actualCurrentHash = hashValue(run);
|
|
1343
|
+
if (actualCurrentHash !== expectedCurrentHash) throw new Error(`stale run.json transition: expected current hash ${expectedCurrentHash}, found ${actualCurrentHash}`);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function steeringGeneration(run) {
|
|
1347
|
+
const value = run?.steering?.generation;
|
|
1348
|
+
return Number.isInteger(value) && value >= 0 ? value : 0;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function normalizedSteeringState(run, overrides = {}) {
|
|
1352
|
+
const current = isRecord(run?.steering) ? cloneJson(run.steering) : {};
|
|
1353
|
+
return {
|
|
1354
|
+
...current,
|
|
1355
|
+
schema_version: 1,
|
|
1356
|
+
generation: steeringGeneration(run),
|
|
1357
|
+
pending: current.pending ?? null,
|
|
1358
|
+
uncheckpointed: current.uncheckpointed ?? null,
|
|
1359
|
+
boundary: current.boundary ?? null,
|
|
1360
|
+
action_claim: current.action_claim ?? null,
|
|
1361
|
+
last_action: current.last_action ?? null,
|
|
1362
|
+
pr_fence: current.pr_fence ?? null,
|
|
1363
|
+
history: Array.isArray(current.history) ? current.history : [],
|
|
1364
|
+
...cloneJson(overrides),
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function assertSteeringBoundaryClear(run, label) {
|
|
1369
|
+
if (isRecord(run?.steering?.pending)) throw new Error(`${label} rejected: pending steering`);
|
|
1370
|
+
if (isRecord(run?.steering?.uncheckpointed)) throw new Error(`${label} rejected: consumed steering acknowledgement is pending`);
|
|
1371
|
+
if (isRecord(run?.steering?.action_claim)) throw new Error(`${label} rejected: action start acknowledgement is pending`);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function assertBoundaryClean(runDir, run, options, label) {
|
|
1375
|
+
if (TERMINAL_RUN_STATUSES.has(run.status)) throw new Error(`${label} rejected: terminal run '${run.status}'`);
|
|
1376
|
+
if (run.status !== "running") throw new Error(`${label} requires a running run, found '${run.status}'`);
|
|
1377
|
+
assertSteeringBoundaryClear(run, label);
|
|
1378
|
+
if (isRecord(run.steering?.pr_fence)) throw new Error(`${label} rejected: active pre-PR fence`);
|
|
1379
|
+
assertNoFreshHeartbeat(runDir, options, `${label} requires inactive heartbeat`);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function normalizeSteeringBoundaryKind(kind) {
|
|
1383
|
+
const value = requireNonEmptyString(kind, "boundary kind").trim();
|
|
1384
|
+
if (!STEERING_BOUNDARY_KINDS.has(value)) throw new Error(`boundary kind must be one of ${[...STEERING_BOUNDARY_KINDS].join(", ")}`);
|
|
1385
|
+
return value;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function normalizeSteeringActionKind(kind) {
|
|
1389
|
+
const value = requireNonEmptyString(kind, "action kind").trim();
|
|
1390
|
+
if (!STEERING_ACTION_KINDS.has(value)) throw new Error("action kind must be dispatch, remediation, terminal, post-pr-observe, or post-pr-push");
|
|
1391
|
+
return value;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function safeBoundaryToken(value) {
|
|
1395
|
+
const token = requireNonEmptyString(value, "boundary token").trim();
|
|
1396
|
+
if (!/^[A-Za-z0-9_-]{8,128}$/u.test(token)) throw new Error("boundary token must use 8-128 safe characters");
|
|
1397
|
+
return token;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function steeringBoundaryStateHash(run) {
|
|
1401
|
+
const copy = cloneJson(run);
|
|
1402
|
+
copy.steering = normalizedSteeringState(copy, { boundary: null, pr_fence: null });
|
|
1403
|
+
return hashValue(validateRun(copy));
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function assertSteeringActionClaim(run, kind, token) {
|
|
1407
|
+
if (TERMINAL_RUN_STATUSES.has(run.status)) throw new Error(`action acknowledgement rejected: terminal run '${run.status}'`);
|
|
1408
|
+
if (run.status !== "running") throw new Error(`action acknowledgement requires a running run, found '${run.status}'`);
|
|
1409
|
+
if (isRecord(run.steering?.pending)) throw new Error("action acknowledgement rejected: pending steering");
|
|
1410
|
+
if (isRecord(run.steering?.uncheckpointed)) throw new Error("action acknowledgement rejected: consumed steering acknowledgement is pending");
|
|
1411
|
+
if (isRecord(run.steering?.pr_fence)) throw new Error("action acknowledgement rejected: active pre-PR fence");
|
|
1412
|
+
const claim = run.steering?.action_claim;
|
|
1413
|
+
if (!isRecord(claim)) throw new Error("run has no action start claim");
|
|
1414
|
+
const requestedToken = safeBoundaryToken(token);
|
|
1415
|
+
if (claim.kind !== kind || claim.token !== requestedToken) throw new Error("action start claim token mismatch");
|
|
1416
|
+
if (claim.generation !== steeringGeneration(run)) throw new Error("action start claim is stale");
|
|
1417
|
+
return claim;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function consumeSteeringBoundary(draft, kind, token) {
|
|
1421
|
+
assertSteeringBoundaryClear(draft, kind);
|
|
1422
|
+
if (isRecord(draft.steering?.pr_fence)) throw new Error(`${kind} rejected: active pre-PR fence`);
|
|
1423
|
+
const boundary = draft.steering?.boundary;
|
|
1424
|
+
if (!isRecord(boundary)) throw new Error(`${kind} requires a lock-protected boundary observation`);
|
|
1425
|
+
const requestedToken = safeBoundaryToken(token);
|
|
1426
|
+
if (boundary.kind !== kind || boundary.token !== requestedToken) throw new Error(`${kind} boundary token mismatch`);
|
|
1427
|
+
if (boundary.generation !== steeringGeneration(draft)) throw new Error(`${kind} boundary observation is stale`);
|
|
1428
|
+
if (boundary.state_hash !== steeringBoundaryStateHash(draft)) throw new Error(`${kind} boundary observation is stale`);
|
|
1429
|
+
draft.steering = normalizedSteeringState(draft, { boundary: null });
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function assertPrFence(run, token) {
|
|
1433
|
+
assertSteeringBoundaryClear(run, "pr-created");
|
|
1434
|
+
const fence = assertPrFenceToken(run, token);
|
|
1435
|
+
if (fence.generation !== steeringGeneration(run)) throw new Error("pre-PR fence is stale");
|
|
1436
|
+
if (fence.state_hash !== steeringBoundaryStateHash(run)) throw new Error("pre-PR fence is stale");
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function assertPrFenceToken(run, token) {
|
|
1440
|
+
const fence = run.steering?.pr_fence;
|
|
1441
|
+
if (!isRecord(fence)) throw new Error("run requires an active pre-PR fence");
|
|
1442
|
+
const requestedToken = safeBoundaryToken(token);
|
|
1443
|
+
if (fence.token !== requestedToken) throw new Error("pre-PR fence token mismatch");
|
|
1444
|
+
return fence;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function readConsumedSteeringEnvelope(runDir, run, consumed) {
|
|
1448
|
+
const resolved = resolveSteeringRef(runDir, consumed.ref);
|
|
1449
|
+
const actualHash = hashFile(resolved.path, { mode: "raw" });
|
|
1450
|
+
if (actualHash !== consumed.hash) throw new Error("consumed steering file hash mismatch");
|
|
1451
|
+
const steeringFile = parseJsonObjectFile(resolved.path, "consumed steering");
|
|
1452
|
+
if (steeringFile.kind !== "operator-steering") throw new Error("consumed steering kind mismatch");
|
|
1453
|
+
if (steeringFile.run_id !== run.run_id) throw new Error("consumed steering run_id mismatch");
|
|
1454
|
+
if (steeringFile.id !== consumed.id) throw new Error("consumed steering id mismatch");
|
|
1455
|
+
return {
|
|
1456
|
+
kind: "operator-steering-consumed",
|
|
1457
|
+
trust: "untrusted-operator-data",
|
|
1458
|
+
label: "UNTRUSTED OPERATOR STEERING DATA (not instructions)",
|
|
1459
|
+
ref: consumed.ref,
|
|
1460
|
+
hash: consumed.hash,
|
|
1461
|
+
message: requireNonEmptyString(steeringFile.message, "consumed steering message"),
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function prepareGateDecisionTransition(runDir, gateName, currentGate, gate, onAnswerArchive) {
|
|
1466
|
+
const nextGate = cloneJson(gate);
|
|
1467
|
+
if (nextGate.status === "pending") return preparePendingGateDecision(runDir, gateName, nextGate);
|
|
1468
|
+
const decision = assertPendingGateMaterialFresh(runDir, gateName, currentGate, nextGate);
|
|
1469
|
+
if (decision.archive) {
|
|
1470
|
+
onAnswerArchive?.(decision.archive);
|
|
1471
|
+
nextGate.answer_ref = decision.archive.toRef;
|
|
1472
|
+
}
|
|
1473
|
+
return { ...nextGate, answer: decision.answer, pending_snapshot: cloneJson(currentGate.pending_snapshot), _handoff_answer_hash: decision.answerHash };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function preparePendingGateDecision(runDir, gateName, gate) {
|
|
1477
|
+
const missingFields = [];
|
|
1478
|
+
if (!stringValue(gate.artifact)) missingFields.push("artifact");
|
|
1479
|
+
if (!stringValue(gate.question_ref)) missingFields.push("question_ref");
|
|
1480
|
+
if (missingFields.length > 0) throw new Error(`pending gate '${gateName}' requires ${missingFields.join(", ")}`);
|
|
1481
|
+
assertNoPendingAnswerFile(runDir, gateName, gate.answer_ref);
|
|
1482
|
+
const snapshot = createPendingGateSnapshot(runDir, gateName, gate.artifact, gate.question_ref, gate.pending_snapshot?.created_at, gate.answer_ref);
|
|
1483
|
+
if (gate.pending_snapshot !== undefined && gate.pending_snapshot !== null) assertPendingSnapshotMatches(gateName, gate.pending_snapshot, snapshot, "supplied pending snapshot");
|
|
1484
|
+
return { ...gate, pending_snapshot: snapshot };
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function assertPendingGateMaterialFresh(runDir, gateName, currentGate, gate) {
|
|
1488
|
+
if (!isRecord(currentGate) || currentGate.status !== "pending") throw new Error(`gate decision '${gateName}' requires current gate status pending`);
|
|
1489
|
+
if (!isRecord(currentGate.pending_snapshot)) throw new Error(`gate decision '${gateName}' requires current pending material snapshot`);
|
|
1490
|
+
if (!stringValue(gate?.artifact)) throw new Error(`gate decision '${gateName}' requires artifact`);
|
|
1491
|
+
if (!stringValue(gate?.question_ref)) throw new Error(`gate decision '${gateName}' requires question_ref`);
|
|
1492
|
+
const freshSnapshot = createPendingGateSnapshot(runDir, gateName, currentGate.pending_snapshot.artifact_ref, currentGate.pending_snapshot.question_ref, currentGate.pending_snapshot.created_at, currentGate.pending_snapshot.answer_ref);
|
|
1493
|
+
assertPendingSnapshotMatches(gateName, currentGate.pending_snapshot, freshSnapshot, "current pending snapshot");
|
|
1494
|
+
if (gate.artifact !== currentGate.pending_snapshot.artifact_ref) throw new Error(`gate decision '${gateName}' artifact must match pending artifact '${currentGate.pending_snapshot.artifact_ref}'`);
|
|
1495
|
+
if (gate.question_ref !== currentGate.pending_snapshot.question_ref) throw new Error(`gate decision '${gateName}' question_ref must match pending question_ref '${currentGate.pending_snapshot.question_ref}'`);
|
|
1496
|
+
if (stringValue(gate.answer_ref) && stringValue(currentGate.pending_snapshot.answer_ref) && gate.answer_ref !== currentGate.pending_snapshot.answer_ref) {
|
|
1497
|
+
throw new Error(`gate decision '${gateName}' answer_ref must match pending answer_ref '${currentGate.pending_snapshot.answer_ref}'`);
|
|
1498
|
+
}
|
|
1499
|
+
const decision = readGateDecisionAnswer(runDir, gateName, gate);
|
|
1500
|
+
assertGateAnswerMatchesStatus(gateName, gate.status, decision.answer);
|
|
1501
|
+
return decision;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function assertNoPendingAnswerFile(runDir, gateName, answerRef) {
|
|
1505
|
+
if (!stringValue(answerRef)) return;
|
|
1506
|
+
const answer = resolveGateRef(runDir, answerRef, { mustExist: false });
|
|
1507
|
+
if (existsSync(answer.path)) throw new Error(`pending gate '${gateName}' answer_ref already exists; archive or delete it before re-pending`);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function createPendingGateSnapshot(runDir, gateName, artifactRef, questionRef, createdAt, answerRef) {
|
|
1511
|
+
const artifact = resolveArtifactRef(runDir, artifactRef);
|
|
1512
|
+
const question = resolveGateRef(runDir, questionRef);
|
|
1513
|
+
const snapshot = {
|
|
1514
|
+
question_ref: questionRef,
|
|
1515
|
+
question_hash: hashFile(question.path, { mode: "raw" }),
|
|
1516
|
+
artifact_ref: artifactRef,
|
|
1517
|
+
artifact_hash: hashFile(artifact.path, { mode: "raw" }),
|
|
1518
|
+
created_at: stringValue(createdAt) ? createdAt : new Date().toISOString(),
|
|
1519
|
+
};
|
|
1520
|
+
if (stringValue(answerRef)) {
|
|
1521
|
+
const answer = resolveGateRef(runDir, answerRef, { mustExist: false });
|
|
1522
|
+
if (answer.path === question.path) throw new Error(`gate '${gateName}' answer_ref must not overlap question_ref`);
|
|
1523
|
+
snapshot.answer_ref = answerRef;
|
|
1524
|
+
if (existsSync(answer.path)) snapshot.answer_hash = hashFile(answer.path, { mode: "raw" });
|
|
1525
|
+
}
|
|
1526
|
+
return snapshot;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function assertPendingSnapshotMatches(gateName, actual, expected, label) {
|
|
1530
|
+
for (const [field, actualValue, expectedValue] of [
|
|
1531
|
+
["question_ref", actual?.question_ref, expected.question_ref],
|
|
1532
|
+
["question_hash", actual?.question_hash, expected.question_hash],
|
|
1533
|
+
["artifact_ref", actual?.artifact_ref, expected.artifact_ref],
|
|
1534
|
+
["artifact_hash", actual?.artifact_hash, expected.artifact_hash],
|
|
1535
|
+
]) {
|
|
1536
|
+
if (actualValue !== expectedValue) throw new Error(`gate '${gateName}' ${label} ${field} is stale or mismatched`);
|
|
1537
|
+
}
|
|
1538
|
+
if (stringValue(expected.answer_ref) && actual?.answer_ref !== expected.answer_ref) throw new Error(`gate '${gateName}' ${label} answer_ref is stale or mismatched`);
|
|
1539
|
+
if (!stringValue(expected.answer_ref) && stringValue(actual?.answer_ref)) throw new Error(`gate '${gateName}' ${label} answer_ref is unexpected`);
|
|
1540
|
+
if (stringValue(actual?.answer_hash) && actual.answer_hash !== expected.answer_hash) throw new Error(`gate '${gateName}' ${label} answer_hash is stale or mismatched`);
|
|
1541
|
+
if (!stringValue(actual?.created_at)) throw new Error(`gate '${gateName}' ${label} created_at is missing`);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function createApprovalHandoffReceipt(_runDir, gateName, gate, run) {
|
|
1545
|
+
const acceptedAt = gate.answered_at;
|
|
1546
|
+
const receipt = {
|
|
1547
|
+
schema_version: 1,
|
|
1548
|
+
kind: "interactive-approval-handoff",
|
|
1549
|
+
gate: gateName,
|
|
1550
|
+
approval_fingerprint: "",
|
|
1551
|
+
pending_snapshot_hash: hashValue(gate.pending_snapshot),
|
|
1552
|
+
answer_hash: gate._handoff_answer_hash,
|
|
1553
|
+
steering_generation: steeringGeneration(run),
|
|
1554
|
+
accepted_at: acceptedAt,
|
|
1555
|
+
};
|
|
1556
|
+
receipt.approval_fingerprint = approvalFingerprint(gateName, gate, receipt);
|
|
1557
|
+
return receipt;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function approvalFingerprint(gateName, gate, receipt) {
|
|
1561
|
+
return hashValue({
|
|
1562
|
+
gate: gateName,
|
|
1563
|
+
status: gate.status,
|
|
1564
|
+
artifact: gate.artifact,
|
|
1565
|
+
question_ref: gate.question_ref,
|
|
1566
|
+
answer_ref: gate.answer_ref || null,
|
|
1567
|
+
answer: gate.answer,
|
|
1568
|
+
approval_source: gate.approval_source,
|
|
1569
|
+
decision_note: gate.decision_note || null,
|
|
1570
|
+
answered_at: gate.answered_at,
|
|
1571
|
+
pending_snapshot_hash: receipt.pending_snapshot_hash,
|
|
1572
|
+
answer_hash: receipt.answer_hash,
|
|
1573
|
+
steering_generation: receipt.steering_generation,
|
|
1574
|
+
accepted_at: receipt.accepted_at,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function approvedAnswerHash(runDir, gate) {
|
|
1579
|
+
if (stringValue(gate.answer_ref)) {
|
|
1580
|
+
try { return hashFile(resolveGateRef(runDir, gate.answer_ref).path, { mode: "raw" }); } catch { return null; }
|
|
1581
|
+
}
|
|
1582
|
+
return hashValue(gate.answer);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function validateApprovalReceiptMaterial(receipt, gateName) {
|
|
1586
|
+
if (receipt.schema_version !== 1 || receipt.kind !== "interactive-approval-handoff" || receipt.gate !== gateName) throw new Error("invalid receipt identity");
|
|
1587
|
+
for (const field of ["approval_fingerprint", "pending_snapshot_hash", "answer_hash"]) {
|
|
1588
|
+
if (typeof receipt[field] !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(receipt[field])) throw new Error(`invalid receipt ${field}`);
|
|
1589
|
+
}
|
|
1590
|
+
if (!Number.isInteger(receipt.steering_generation) || receipt.steering_generation < 0) throw new Error("invalid receipt steering generation");
|
|
1591
|
+
if (!stringValue(receipt.accepted_at) || !Number.isFinite(Date.parse(receipt.accepted_at))) throw new Error("invalid receipt accepted_at");
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function cleanSteeringForHandoff(steering) {
|
|
1595
|
+
if (steering === undefined || steering === null) return true;
|
|
1596
|
+
if (!isRecord(steering)) return false;
|
|
1597
|
+
return !isRecord(steering.pending)
|
|
1598
|
+
&& !isRecord(steering.uncheckpointed)
|
|
1599
|
+
&& !isRecord(steering.boundary)
|
|
1600
|
+
&& !isRecord(steering.action_claim)
|
|
1601
|
+
&& !isRecord(steering.pr_fence);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function reconcileApprovedGateRedelivery(runDir, run, gateName, requested, input = {}) {
|
|
1605
|
+
const approved = run.gates[gateName];
|
|
1606
|
+
const receiptCheck = inspectApprovalHandoffReceipt(runDir, run, gateName);
|
|
1607
|
+
if (!receiptCheck.ok) throw handoffReceiptError(receiptCheck.reason_code);
|
|
1608
|
+
const same = requested.status === "approved"
|
|
1609
|
+
&& requested.artifact === approved.artifact
|
|
1610
|
+
&& requested.question_ref === approved.question_ref
|
|
1611
|
+
&& requested.approval_source === approved.approval_source
|
|
1612
|
+
&& (requested.decision_note || null) === (approved.decision_note || null)
|
|
1613
|
+
&& (!input.answeredAtExplicit || requested.answered_at === approved.answered_at)
|
|
1614
|
+
&& exactRedeliveredAnswer(runDir, requested, approved, receiptCheck.receipt, input);
|
|
1615
|
+
if (!same) throw handoffReceiptError("approval-snapshot-mismatch");
|
|
1616
|
+
return { updated: false, reason: "redelivered-approved", status: run.status, run };
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function exactRedeliveredAnswer(runDir, requested, approved, receipt, input = {}) {
|
|
1620
|
+
if (stringValue(requested.answer)) return normalizeGateAnswer(receipt.gate, requested.answer) === approved.answer && hashValue(input.rawAnswer ?? requested.answer) === receipt.answer_hash;
|
|
1621
|
+
if (!stringValue(requested.answer_ref) || requested.answer_ref !== approved.pending_snapshot?.answer_ref) return false;
|
|
1622
|
+
// The ingress file is expected to have moved to the archived approved ref.
|
|
1623
|
+
return approvedAnswerHash(runDir, approved) === receipt.answer_hash;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function handoffReceiptError(code) {
|
|
1627
|
+
const error = new Error(code);
|
|
1628
|
+
error.handoffCode = code;
|
|
1629
|
+
return error;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function readGateDecisionAnswer(runDir, gateName, gate) {
|
|
1633
|
+
if (stringValue(gate.answer)) {
|
|
1634
|
+
const answer = normalizeGateAnswer(gateName, String(gate.answer).trim());
|
|
1635
|
+
return { answer, answerHash: hashValue(String(gate.answer)), archive: null };
|
|
1636
|
+
}
|
|
1637
|
+
const answerRef = requireNonEmptyString(gate.answer_ref, "answer_ref");
|
|
1638
|
+
const answer = readGateDecisionAnswerRef(runDir, gateName, answerRef);
|
|
1639
|
+
return {
|
|
1640
|
+
answer: normalizeGateAnswer(gateName, answer.text),
|
|
1641
|
+
answerHash: hashFile(answer.path, { mode: "raw" }),
|
|
1642
|
+
archive: nextConsumedGateAnswer(runDir, answerRef, answer.path),
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function readGateDecisionAnswerRef(runDir, gateName, answerRef) {
|
|
1647
|
+
if (!stringValue(answerRef)) throw new Error(`gate decision '${gateName}' requires answer_ref or answer`);
|
|
1648
|
+
const resolved = resolveGateRef(runDir, answerRef);
|
|
1649
|
+
return { text: readFileSync(resolved.path, "utf8").trim(), path: resolved.path };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function nextConsumedGateAnswer(runDir, answerRef, answerPath) {
|
|
1653
|
+
for (let index = 1; index < 1000; index += 1) {
|
|
1654
|
+
const toRef = `${answerRef}.consumed-${index}`;
|
|
1655
|
+
const to = resolveGateRef(runDir, toRef, { mustExist: false });
|
|
1656
|
+
if (!existsSync(to.path)) return { fromPath: answerPath, toPath: to.path, toRef };
|
|
1657
|
+
}
|
|
1658
|
+
throw new Error(`unable to allocate consumed answer ref for ${answerRef}`);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function resolvePendingSteeringSource(runDir, pending) {
|
|
1662
|
+
const pendingResolved = resolveSteeringRef(runDir, pending.ref, { mustExist: false });
|
|
1663
|
+
if (existsSync(pendingResolved.path)) return { path: pendingResolved.path, consumedRef: null };
|
|
1664
|
+
// Crash recovery: a prior consume renamed the pending file to its consumed
|
|
1665
|
+
// path but died before recording the consumption in run.json. Locate it by
|
|
1666
|
+
// the consumed naming convention plus the recorded hash and finish the
|
|
1667
|
+
// interrupted consume instead of stranding the run.
|
|
1668
|
+
const recovered = findConsumedSteeringByHash(runDir, pending);
|
|
1669
|
+
if (recovered) return recovered;
|
|
1670
|
+
throw new Error(`missing pending steering file: ${pending.ref}`);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function findConsumedSteeringByHash(runDir, pending) {
|
|
1674
|
+
const safeId = safeSteeringId(pending.id);
|
|
1675
|
+
let names;
|
|
1676
|
+
try {
|
|
1677
|
+
names = readdirSync(join(runDir, "steering"));
|
|
1678
|
+
} catch {
|
|
1679
|
+
return null;
|
|
1680
|
+
}
|
|
1681
|
+
for (const name of names) {
|
|
1682
|
+
if (!name.startsWith("consumed-") || !name.endsWith(".json") || !name.includes(`-${safeId}`)) continue;
|
|
1683
|
+
const ref = `steering/${name}`;
|
|
1684
|
+
const resolved = resolveSteeringRef(runDir, ref, { mustExist: false });
|
|
1685
|
+
if (!existsSync(resolved.path)) continue;
|
|
1686
|
+
if (hashFile(resolved.path, { mode: "raw" }) !== pending.hash) continue;
|
|
1687
|
+
return { path: resolved.path, consumedRef: ref };
|
|
1688
|
+
}
|
|
1689
|
+
return null;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function nextConsumedSteeringRef(runDir, id, consumedAt) {
|
|
1693
|
+
const safeId = safeSteeringId(id);
|
|
1694
|
+
const base = `steering/consumed-${safeTimestamp(consumedAt)}-${safeId}`;
|
|
1695
|
+
for (let index = 0; index < 1000; index += 1) {
|
|
1696
|
+
const suffix = index === 0 ? "" : `-${index}`;
|
|
1697
|
+
const ref = `${base}${suffix}.json`;
|
|
1698
|
+
const resolved = resolveSteeringRef(runDir, ref, { mustExist: false });
|
|
1699
|
+
if (!existsSync(resolved.path)) return ref;
|
|
1700
|
+
}
|
|
1701
|
+
throw new Error(`unable to allocate consumed steering ref for ${safeId}`);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function safeSteeringId(value) {
|
|
1705
|
+
const text = requireNonEmptyString(value, "steering id").trim();
|
|
1706
|
+
return text.replace(/[^A-Za-z0-9_-]/gu, "-").replace(/-+/gu, "-").replace(/^-|-$/gu, "").slice(0, 80) || "steering";
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function safeTimestamp(value) {
|
|
1710
|
+
return requireNonEmptyString(value, "timestamp").replace(/[^0-9A-Za-z]/gu, "-").replace(/-+/gu, "-").replace(/^-|-$/gu, "");
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
async function archiveConsumedGateAnswer(archive) {
|
|
1714
|
+
await rename(archive.fromPath, archive.toPath);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function normalizeGateAnswer(gateName, answer) {
|
|
1718
|
+
const text = String(answer || "").trim();
|
|
1719
|
+
if (text === "approve" || text === "stop") return text;
|
|
1720
|
+
if (text.startsWith("changes:") && text.slice("changes:".length).trim().length > 0) return text;
|
|
1721
|
+
throw new Error(`gate decision '${gateName}' answer must be exactly approve, stop, or start with changes:`);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function assertGateAnswerMatchesStatus(gateName, status, answer) {
|
|
1725
|
+
if (status === "approved" && answer !== "approve") throw new Error(`gate decision '${gateName}' approved status requires approve answer`);
|
|
1726
|
+
if (status === "changes_requested" && !answer.startsWith("changes:")) throw new Error(`gate decision '${gateName}' changes_requested status requires changes: answer`);
|
|
1727
|
+
if (status === "stopped" && answer !== "stop") throw new Error(`gate decision '${gateName}' stopped status requires stop answer`);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function normalizeGateDecision(gateName, gate, options = {}) {
|
|
1731
|
+
if (!isRecord(gate)) throw new Error(`transitionGateDecision requires a gate object for '${gateName}'`);
|
|
1732
|
+
const next = cloneJson(gate);
|
|
1733
|
+
if (!stringValue(next.status)) throw new Error(`gate decision '${gateName}' requires status`);
|
|
1734
|
+
if (next.status !== "pending") {
|
|
1735
|
+
const hasAnswerRef = stringValue(next.answer_ref);
|
|
1736
|
+
const hasAnswer = stringValue(next.answer);
|
|
1737
|
+
if (hasAnswerRef === hasAnswer) throw new Error(`gate decision '${gateName}' requires exactly one of answer_ref or answer`);
|
|
1738
|
+
next.approval_source ||= defaultApprovalSource(next, options);
|
|
1739
|
+
next.answered_at ||= timestamp(options.now);
|
|
1740
|
+
}
|
|
1741
|
+
return next;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function defaultApprovalSource(gate, options) {
|
|
1745
|
+
if (stringValue(options.approvalSource)) return options.approvalSource;
|
|
1746
|
+
if (stringValue(gate.answer_ref)) return "external-driver";
|
|
1747
|
+
if (stringValue(gate.answer)) return "human";
|
|
1748
|
+
return "human";
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function assertGateDecisionTransitions(current, next, hooks = {}) {
|
|
1752
|
+
const authorizedGate = stringValue(hooks.authorizedGate) ? hooks.authorizedGate : null;
|
|
1753
|
+
const currentGates = isRecord(current.gates) ? current.gates : {};
|
|
1754
|
+
const nextGates = isRecord(next.gates) ? next.gates : {};
|
|
1755
|
+
const errors = [];
|
|
1756
|
+
for (const [gateName, gate] of Object.entries(nextGates)) {
|
|
1757
|
+
if (!isRecord(gate)) continue;
|
|
1758
|
+
const currentGate = currentGates[gateName];
|
|
1759
|
+
if (currentGate?.status === "pending" && GATE_DECISION_STATUSES.has(gate.status) && gateName !== authorizedGate) {
|
|
1760
|
+
errors.push({ path: `run.gates.${gateName}.status`, message: "pending gate decisions must use transitionGateDecision" });
|
|
1761
|
+
}
|
|
1762
|
+
if (gate.status === "approved" && currentGate?.status !== "approved" && gateName !== authorizedGate) {
|
|
1763
|
+
errors.push({ path: `run.gates.${gateName}.status`, message: "approved gate transitions must use transitionGateDecision" });
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
for (const [gateName, gate] of Object.entries(currentGates)) {
|
|
1767
|
+
if (!isRecord(gate)) continue;
|
|
1768
|
+
const nextGate = nextGates[gateName];
|
|
1769
|
+
if (gate.status === "pending" && nextGate?.status !== "pending" && gateName !== authorizedGate) {
|
|
1770
|
+
errors.push({ path: `run.gates.${gateName}.status`, message: "pending gate decisions must use transitionGateDecision" });
|
|
1771
|
+
}
|
|
1772
|
+
if (gate.status !== "approved") continue;
|
|
1773
|
+
if (nextGate?.status !== "approved" && gateName !== authorizedGate) {
|
|
1774
|
+
errors.push({ path: `run.gates.${gateName}.status`, message: "approved gate transitions must use transitionGateDecision" });
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
if (errors.length > 0) throw new Error(formatErrorItems(errors));
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function initializePostPrObservation(postPr, request, now) {
|
|
1781
|
+
if (postPr.phase !== "awaiting-pr") throw new Error(`enabled pr-created requires post_pr phase awaiting-pr, found '${postPr.phase}'`);
|
|
1782
|
+
if (!stringValue(request.head_sha) || !/^[0-9a-f]{40}$/u.test(request.head_sha)) throw new Error("enabled pr-created requires a full 40-character lowercase head SHA");
|
|
1783
|
+
const startedAt = timestamp(now);
|
|
1784
|
+
const deadlineAt = new Date(Date.parse(startedAt) + postPr.policy.wait_ms).toISOString();
|
|
1785
|
+
return {
|
|
1786
|
+
...cloneJson(postPr),
|
|
1787
|
+
phase: "observing",
|
|
1788
|
+
attempt: 0,
|
|
1789
|
+
observation: {
|
|
1790
|
+
epoch: 1,
|
|
1791
|
+
expected_head_sha: request.head_sha,
|
|
1792
|
+
started_at: startedAt,
|
|
1793
|
+
deadline_at: deadlineAt,
|
|
1794
|
+
next_poll_at: startedAt,
|
|
1795
|
+
poll_count: 0,
|
|
1796
|
+
unchanged_count: 0,
|
|
1797
|
+
current_interval_ms: postPr.policy.initial_poll_ms,
|
|
1798
|
+
consecutive_transient_errors: 0,
|
|
1799
|
+
last_observed_at: null,
|
|
1800
|
+
last_fingerprint: null,
|
|
1801
|
+
last_check_verdict: "not_started",
|
|
1802
|
+
last_review_verdict: postPr.policy.review.required ? "pending" : "not_required",
|
|
1803
|
+
last_verdict: "pending",
|
|
1804
|
+
last_error: null,
|
|
1805
|
+
review_request: postPr.policy.review.required ? { status: "pending", attempts: 0, requested_at: null } : null,
|
|
1806
|
+
snapshot: null,
|
|
1807
|
+
},
|
|
1808
|
+
remediation: null,
|
|
1809
|
+
evidence_refs: Array.isArray(postPr.evidence_refs) ? cloneJson(postPr.evidence_refs) : [],
|
|
1810
|
+
continuation_review: null,
|
|
1811
|
+
terminal_fact: null,
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
function assertPostPrMutationReady(runDir, run, options, label) {
|
|
1816
|
+
if (TERMINAL_RUN_STATUSES.has(run.status)) throw new Error(`${label} rejected: terminal run '${run.status}'`);
|
|
1817
|
+
if (run.status !== "running") throw new Error(`${label} requires a running run`);
|
|
1818
|
+
if (!run.post_pr?.policy?.enabled) throw new Error(`${label} requires enabled persisted post-PR policy`);
|
|
1819
|
+
assertSteeringBoundaryClear(run, label);
|
|
1820
|
+
if (isRecord(run.steering?.boundary) && !(stringValue(options.boundaryToken) && run.steering.boundary.kind === "terminal")) throw new Error(`${label} rejected: open steering boundary`);
|
|
1821
|
+
if (isRecord(run.steering?.pr_fence)) throw new Error(`${label} rejected: active pre-PR fence`);
|
|
1822
|
+
assertNoFreshHeartbeat(runDir, options, `${label} requires inactive heartbeat`);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function assertPostPrPhaseTransition(current, next) {
|
|
1826
|
+
if (!isRecord(current) || !isRecord(next)) throw new Error("post-PR transition requires current and next state");
|
|
1827
|
+
if (current.schema_version !== next.schema_version || !sameJson(current.policy, next.policy)) throw new Error("persisted post-PR schema and policy are immutable");
|
|
1828
|
+
if (current.phase === next.phase) return;
|
|
1829
|
+
if (!POST_PR_TRANSITIONS.get(current.phase)?.has(next.phase)) throw new Error(`invalid post-PR phase transition '${current.phase}' -> '${next.phase}'`);
|
|
1830
|
+
if (current.phase === "remote-confirmed" && next.phase === "observing") {
|
|
1831
|
+
if (next.observation?.epoch !== current.observation?.epoch + 1) throw new Error("new post-PR observation must advance epoch exactly once");
|
|
1832
|
+
if (next.observation?.expected_head_sha !== current.remediation?.candidate_head_sha) throw new Error("new post-PR observation must bind the confirmed candidate head");
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function assertPostPrAttemptTransition(currentRun, nextPostPr) {
|
|
1837
|
+
const current = currentRun.post_pr;
|
|
1838
|
+
if (nextPostPr.attempt !== current.attempt) throw new Error("post-PR attempt changes must use transitionPostPrFailure");
|
|
1839
|
+
if (nextPostPr.attempt > (Number.isInteger(currentRun.max_retries) ? currentRun.max_retries : 3)) throw new Error("post-PR attempt exceeds max_retries");
|
|
1840
|
+
if (isRecord(current.remediation) && nextPostPr.attempt === current.attempt && isRecord(nextPostPr.remediation)
|
|
1841
|
+
&& current.remediation.failure_fingerprint !== nextPostPr.remediation.failure_fingerprint) throw new Error("post-PR failure fingerprint is immutable within an attempt");
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function assertPostPrMonotonicState(current, next) {
|
|
1845
|
+
const currentObservation = current.observation;
|
|
1846
|
+
const nextObservation = next.observation;
|
|
1847
|
+
if (isRecord(currentObservation) && !isRecord(nextObservation)) throw new Error("post-PR observation state cannot be removed");
|
|
1848
|
+
if (isRecord(currentObservation) && isRecord(nextObservation)) {
|
|
1849
|
+
if (nextObservation.epoch < currentObservation.epoch) throw new Error("post-PR observation epoch cannot decrease");
|
|
1850
|
+
if (nextObservation.epoch > currentObservation.epoch && current.phase !== "remote-confirmed") throw new Error("post-PR observation epoch can advance only after remote confirmation");
|
|
1851
|
+
if (nextObservation.epoch === currentObservation.epoch) {
|
|
1852
|
+
for (const key of ["expected_head_sha", "started_at", "deadline_at"]) {
|
|
1853
|
+
if (nextObservation[key] !== currentObservation[key]) throw new Error(`post-PR observation ${key} is immutable within an epoch`);
|
|
1854
|
+
}
|
|
1855
|
+
if (nextObservation.poll_count < currentObservation.poll_count) throw new Error("post-PR observation poll_count cannot decrease");
|
|
1856
|
+
if (dateBefore(nextObservation.last_observed_at, currentObservation.last_observed_at)) throw new Error("post-PR last_observed_at cannot move backwards");
|
|
1857
|
+
if (nextObservation.last_fingerprint === currentObservation.last_fingerprint && nextObservation.unchanged_count < currentObservation.unchanged_count) throw new Error("post-PR unchanged_count cannot decrease for the same fingerprint");
|
|
1858
|
+
if (nextObservation.last_fingerprint === currentObservation.last_fingerprint && nextObservation.current_interval_ms < currentObservation.current_interval_ms) throw new Error("post-PR poll interval cannot decrease for the same fingerprint");
|
|
1859
|
+
if (Date.parse(nextObservation.next_poll_at) < Date.parse(currentObservation.next_poll_at)) throw new Error("post-PR next_poll_at cannot move backwards within an epoch");
|
|
1860
|
+
const resultChanged = !sameJson(observationResultIdentity(currentObservation), observationResultIdentity(nextObservation));
|
|
1861
|
+
if (resultChanged && nextObservation.poll_count <= currentObservation.poll_count) throw new Error("post-PR observation result changes must advance poll_count");
|
|
1862
|
+
if (nextObservation.consecutive_transient_errors < currentObservation.consecutive_transient_errors
|
|
1863
|
+
&& !(nextObservation.consecutive_transient_errors === 0 && nextObservation.last_error === null && resultChanged)) throw new Error("post-PR transient error counter can reset only after a valid observation");
|
|
1864
|
+
assertMonotonicReviewerRequest(currentObservation.review_request, nextObservation.review_request);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
const currentRemediation = current.remediation;
|
|
1868
|
+
const nextRemediation = next.remediation;
|
|
1869
|
+
if (isRecord(currentRemediation) && !isRecord(nextRemediation)) throw new Error("post-PR remediation identity cannot be removed");
|
|
1870
|
+
if (isRecord(currentRemediation) && isRecord(nextRemediation) && currentRemediation.attempt === nextRemediation.attempt) {
|
|
1871
|
+
for (const key of ["schema_version", "attempt", "reason_code", "failure_fingerprint", "failed_head_sha", "failure_evidence_ref", "failure_evidence_hash", "owner", "route", "lane", "baseline_head_sha"]) {
|
|
1872
|
+
if (!sameJson(currentRemediation[key], nextRemediation[key])) throw new Error(`post-PR remediation ${key} is immutable within an attempt`);
|
|
1873
|
+
}
|
|
1874
|
+
for (const key of ["id", "role", "subject"]) if (currentRemediation.dispatch?.[key] !== nextRemediation.dispatch?.[key]) throw new Error(`post-PR remediation dispatch.${key} is immutable within an attempt`);
|
|
1875
|
+
assertRankDoesNotDecrease(currentRemediation.stage, nextRemediation.stage, ["planned", "running", "changes-observed", "committed", "revalidating", "validated", "push-pending", "remote-confirmed"], "post-PR remediation stage");
|
|
1876
|
+
assertRankDoesNotDecrease(currentRemediation.dispatch?.status, nextRemediation.dispatch?.status, ["planned", "running", "returned"], "post-PR remediation dispatch status");
|
|
1877
|
+
for (const key of ["candidate_head_sha", "remediation_evidence_ref", "remediation_evidence_hash"]) assertOnceBound(currentRemediation, nextRemediation, key, `post-PR remediation ${key}`);
|
|
1878
|
+
for (const key of ["canonical_evidence_ref", "canonical_evidence_hash", "canonical_verdict", "validator_review_ref", "validator_review_hash", "validator_verdict", "security_review_ref", "security_review_hash", "security_verdict"]) assertOnceBound(currentRemediation.revalidation, nextRemediation.revalidation, key, `post-PR remediation revalidation.${key}`);
|
|
1879
|
+
for (const activity of ["canonical", "validator", "security"]) assertPostPrJobMonotonic(currentRemediation.revalidation?.jobs?.[activity], nextRemediation.revalidation?.jobs?.[activity], activity);
|
|
1880
|
+
for (const key of ["remote_before_sha", "local_head_sha", "remote_after_sha", "pushed_at"]) assertOnceBound(currentRemediation.push, nextRemediation.push, key, `post-PR remediation push.${key}`);
|
|
1881
|
+
assertRankDoesNotDecrease(currentRemediation.push?.status, nextRemediation.push?.status, ["not-ready", "pending", "confirmed"], "post-PR remediation push status");
|
|
1882
|
+
if (["changes-observed", "committed", "revalidating", "validated", "push-pending", "remote-confirmed"].includes(currentRemediation.stage) && !sameJson(currentRemediation.changes, nextRemediation.changes)) throw new Error("post-PR observed remediation changes are immutable");
|
|
1883
|
+
}
|
|
1884
|
+
const currentRefs = new Map((current.evidence_refs || []).map((item) => [item.ref, item.hash]));
|
|
1885
|
+
const nextRefs = new Map((next.evidence_refs || []).map((item) => [item.ref, item.hash]));
|
|
1886
|
+
for (const [ref, hash] of currentRefs) if (nextRefs.get(ref) !== hash) throw new Error("post-PR evidence bindings are append-only and immutable");
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function assertPostPrJobMonotonic(current, next, activity) {
|
|
1890
|
+
if (!isRecord(current)) return;
|
|
1891
|
+
if (!isRecord(next)) throw new Error(`post-PR ${activity} job cannot be removed`);
|
|
1892
|
+
if (current.dispatch_id !== next.dispatch_id) throw new Error(`post-PR ${activity} dispatch id is immutable`);
|
|
1893
|
+
const transitions = { planned: new Set(["planned", "running"]), running: new Set(["running", "retry-wait", "bound"]), "retry-wait": new Set(["retry-wait", "running"]), bound: new Set(["bound"]) };
|
|
1894
|
+
if (!transitions[current.status]?.has(next.status)) throw new Error(`invalid post-PR ${activity} job transition '${current.status}' -> '${next.status}'`);
|
|
1895
|
+
for (const key of ["result_ref", "result_hash", "verdict", "returned_at"]) assertOnceBound(current, next, key, `post-PR ${activity} job ${key}`);
|
|
1896
|
+
if (next.transient_error_count < current.transient_error_count) throw new Error(`post-PR ${activity} transient error count cannot decrease`);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function observationResultIdentity(observation) {
|
|
1900
|
+
return Object.fromEntries(["last_observed_at", "last_fingerprint", "last_check_verdict", "last_review_verdict", "last_verdict", "last_error", "snapshot"].map((key) => [key, observation?.[key] ?? null]));
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function assertMonotonicReviewerRequest(current, next) {
|
|
1904
|
+
if (!isRecord(current)) return;
|
|
1905
|
+
if (!isRecord(next)) throw new Error("post-PR reviewer request state cannot be removed");
|
|
1906
|
+
assertRankDoesNotDecrease(current.status, next.status, ["pending", "requested"], "post-PR reviewer request status");
|
|
1907
|
+
if (next.attempts < current.attempts) throw new Error("post-PR reviewer request attempts cannot decrease");
|
|
1908
|
+
assertOnceBound(current, next, "requested_at", "post-PR reviewer requested_at");
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function assertRankDoesNotDecrease(current, next, order, label) {
|
|
1912
|
+
const currentRank = order.indexOf(current);
|
|
1913
|
+
const nextRank = order.indexOf(next);
|
|
1914
|
+
if (currentRank >= 0 && nextRank >= 0 && nextRank < currentRank) throw new Error(`${label} cannot move backwards`);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function assertOnceBound(current, next, key, label) {
|
|
1918
|
+
if (current?.[key] !== undefined && current?.[key] !== null && next?.[key] !== current[key]) throw new Error(`${label} cannot change once bound`);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function dateBefore(next, current) {
|
|
1922
|
+
if (!stringValue(current)) return false;
|
|
1923
|
+
if (!stringValue(next)) return true;
|
|
1924
|
+
return Date.parse(next) < Date.parse(current);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function assertPostPrFailureSource(run, remediation) {
|
|
1928
|
+
const reason = remediation.reason_code;
|
|
1929
|
+
if (reason === "check-red") {
|
|
1930
|
+
if (run.post_pr.phase !== "observing") throw new Error("check-red failure requires observing phase");
|
|
1931
|
+
if (remediation.failed_head_sha !== run.post_pr.observation?.expected_head_sha) throw new Error("check-red failure must bind the current expected observation head");
|
|
1932
|
+
if (run.post_pr.observation?.last_verdict !== "red" || run.post_pr.observation?.last_check_verdict !== "red") throw new Error("check-red failure requires an explicit red check observation");
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
if (reason === "local-red") {
|
|
1936
|
+
if (run.post_pr.phase !== "revalidating") throw new Error("local-red failure requires revalidating phase");
|
|
1937
|
+
if (remediation.failed_head_sha !== run.post_pr.remediation?.candidate_head_sha) throw new Error("local-red failure must bind the current remediation candidate head");
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
throw new Error(`unsupported post-PR failure source '${reason}'`);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function assertPostPrFailureEvidence(runDir, run, remediation) {
|
|
1944
|
+
const resolved = resolveEvidenceRef(runDir, remediation.failure_evidence_ref);
|
|
1945
|
+
const actualHash = hashFile(resolved.path, { mode: "raw" });
|
|
1946
|
+
if (actualHash !== remediation.failure_evidence_hash) throw new Error("post-PR failure evidence exact-byte hash mismatch");
|
|
1947
|
+
const evidence = parseJsonObjectFile(resolved.path, "post-PR failure evidence");
|
|
1948
|
+
const expected = {
|
|
1949
|
+
run_id: run.run_id,
|
|
1950
|
+
attempt: remediation.attempt,
|
|
1951
|
+
source: remediation.reason_code,
|
|
1952
|
+
verdict: "red",
|
|
1953
|
+
failed_head_sha: remediation.failed_head_sha,
|
|
1954
|
+
failure_fingerprint: remediation.failure_fingerprint,
|
|
1955
|
+
};
|
|
1956
|
+
for (const [key, value] of Object.entries(expected)) if (evidence[key] !== value) throw new Error(`post-PR failure evidence ${key} mismatch`);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function assertPostPrFailureReplayContext(run, remediation) {
|
|
1960
|
+
if (run.post_pr.phase !== "observing") return;
|
|
1961
|
+
const observation = run.post_pr.observation;
|
|
1962
|
+
if (remediation.reason_code !== "check-red" || observation?.last_check_verdict !== "red" || observation?.expected_head_sha !== remediation.failed_head_sha) {
|
|
1963
|
+
throw new Error("stale post-PR failure replay does not match the current observation phase/head/source");
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function assertPostPrTerminalPreconditions(run, status, reason, input, options) {
|
|
1968
|
+
const postPr = run.post_pr;
|
|
1969
|
+
const observation = postPr.observation;
|
|
1970
|
+
const remediation = postPr.remediation;
|
|
1971
|
+
const requireObservation = (verdict) => {
|
|
1972
|
+
if (postPr.phase !== "observing" || observation?.last_verdict !== verdict) throw new Error(`${reason} requires observing phase with '${verdict}' verdict`);
|
|
1973
|
+
};
|
|
1974
|
+
if (reason === "post-pr-ci-green" || reason === "post-pr-draft-ci-green") {
|
|
1975
|
+
requireObservation("green");
|
|
1976
|
+
if (reason === "post-pr-draft-ci-green" && run.pr_mode !== "draft") throw new Error("post-pr-draft-ci-green requires draft PR mode");
|
|
1977
|
+
if (reason === "post-pr-ci-green" && run.pr_mode === "draft") throw new Error("draft PR success must use post-pr-draft-ci-green");
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (reason === "post-pr-external-merge") return requireObservation("external-merge");
|
|
1981
|
+
if (reason === "post-pr-pr-closed") return requireObservation("closed");
|
|
1982
|
+
if (reason === "post-pr-head-mismatch") return requireObservation("head-mismatch");
|
|
1983
|
+
if (reason === "post-pr-review-changes-requested") {
|
|
1984
|
+
if (postPr.phase !== "observing" || observation?.last_review_verdict !== "red") throw new Error(`${reason} requires current-head red review observation`);
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
if (reason === "post-pr-observation-timeout") {
|
|
1988
|
+
if (postPr.phase !== "observing" || Date.parse(timestamp(options.now)) < Date.parse(observation?.deadline_at || "")) throw new Error(`${reason} requires an expired observing deadline`);
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
if (reason === "post-pr-observer-infrastructure") {
|
|
1992
|
+
if (postPr.phase !== "observing" || observation?.last_verdict !== "infrastructure") throw new Error(`${reason} requires observing infrastructure verdict`);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
if (reason === "post-pr-account-switch-failed") {
|
|
1996
|
+
if (!["observing", "push-pending"].includes(postPr.phase)) throw new Error(`${reason} requires observing or push-pending phase`);
|
|
1997
|
+
requirePostPrTerminalFact(reason, input.trigger_fact, "account-switch-failed");
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (reason === "post-pr-owner-ambiguous" || reason === "post-pr-metadata-unsafe") {
|
|
2001
|
+
const malformed = input.trigger_fact?.kind === "panel-runner-result-malformed" && postPr.phase === "revalidating";
|
|
2002
|
+
if (!malformed && (!['observing', 'failure-recording', 'revalidating'].includes(postPr.phase) || postPr.phase !== "revalidating" && observation?.last_verdict !== "red")) throw new Error(`${reason} requires an explicit red observation or unsafe revalidation metadata`);
|
|
2003
|
+
if (malformed) requirePostPrTerminalFact(reason, input.trigger_fact, "panel-runner-result-malformed");
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (reason === "post-pr-dispatch-start-unknown") {
|
|
2007
|
+
const activity = input.trigger_fact?.activity || "remediation";
|
|
2008
|
+
const running = activity === "remediation" ? postPr.phase === "remediation-running" && remediation?.dispatch?.status === "running" : postPr.phase === "revalidating" && remediation?.revalidation?.jobs?.[activity]?.status === "running";
|
|
2009
|
+
if (!running) throw new Error(`${reason} requires a running remediation or revalidation dispatch`);
|
|
2010
|
+
requirePostPrTerminalFact(reason, input.trigger_fact, "dispatch-start-unknown");
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
if (reason === "post-pr-path-lane-violation") {
|
|
2014
|
+
if (!["remediation-running", "changes-observed", "committed"].includes(postPr.phase)) throw new Error(`${reason} requires active remediation changes`);
|
|
2015
|
+
requirePostPrTerminalFact(reason, input.trigger_fact, "path-lane-violation");
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
if (reason === "post-pr-remote-head-diverged") {
|
|
2019
|
+
if (postPr.phase !== "push-pending" || remediation?.stage !== "push-pending") throw new Error(`${reason} requires push-pending remediation`);
|
|
2020
|
+
requirePostPrTerminalFact(reason, input.trigger_fact, "remote-head-diverged");
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
if (reason === "post-pr-push-failed") {
|
|
2024
|
+
if (postPr.phase !== "push-pending") throw new Error(`${reason} requires push-pending remediation`);
|
|
2025
|
+
requirePostPrTerminalFact(reason, input.trigger_fact, "push-failed");
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
if (reason === "post-pr-panel-attribution-unsafe") {
|
|
2029
|
+
if (postPr.phase !== "revalidating") throw new Error(`${reason} requires revalidating remediation`);
|
|
2030
|
+
requirePostPrTerminalFact(reason, input.trigger_fact, "panel-attribution-unsafe");
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
if (reason === "post-pr-retry-exhausted") {
|
|
2034
|
+
const max = Number.isInteger(run.max_retries) ? run.max_retries : 3;
|
|
2035
|
+
if (postPr.attempt !== max || !isRecord(remediation) || remediation.attempt !== max) throw new Error(`${reason} requires attempt equal to max_retries`);
|
|
2036
|
+
if (!["observing", "failure-recording", "revalidating"].includes(postPr.phase)) throw new Error(`${reason} requires an explicit red failure phase`);
|
|
2037
|
+
if (postPr.phase === "observing" && (observation?.last_check_verdict !== "red" || observation?.expected_head_sha !== remediation.candidate_head_sha)) throw new Error(`${reason} observing exhaustion requires red checks on the current candidate head`);
|
|
2038
|
+
if (postPr.phase === "revalidating" && !stringValue(remediation.candidate_head_sha)) throw new Error(`${reason} revalidation exhaustion requires a current candidate head`);
|
|
2039
|
+
if (postPr.phase === "failure-recording" && remediation.reason_code === "check-red" && (observation?.last_check_verdict !== "red" || remediation.failed_head_sha !== observation?.expected_head_sha)) throw new Error(`${reason} check exhaustion requires red checks on the expected head`);
|
|
2040
|
+
if (postPr.phase === "failure-recording" && remediation.reason_code === "local-red" && remediation.failed_head_sha !== remediation.baseline_head_sha) throw new Error(`${reason} local exhaustion requires the failed candidate head`);
|
|
2041
|
+
if (!input.continuation_review) throw new Error(`${reason} requires a continuation review binding`);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function requirePostPrTerminalFact(reason, fact, kind) {
|
|
2046
|
+
if (!isRecord(fact) || fact.kind !== kind) throw new Error(`${reason} requires persisted ${kind} trigger fact`);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function normalizedPostPrTerminalFact(reason, fact) {
|
|
2050
|
+
const factReasons = new Set(["post-pr-account-switch-failed", "post-pr-dispatch-start-unknown", "post-pr-path-lane-violation", "post-pr-remote-head-diverged", "post-pr-push-failed", "post-pr-panel-attribution-unsafe"]);
|
|
2051
|
+
if (reason === "post-pr-metadata-unsafe" && fact?.kind === "panel-runner-result-malformed") return cloneJson(fact);
|
|
2052
|
+
if (!factReasons.has(reason)) {
|
|
2053
|
+
if (fact !== undefined && fact !== null) throw new Error(`${reason} does not accept a terminal trigger fact`);
|
|
2054
|
+
return null;
|
|
2055
|
+
}
|
|
2056
|
+
if (!isRecord(fact)) throw new Error(`${reason} requires a terminal trigger fact`);
|
|
2057
|
+
return cloneJson(fact);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function bindPostPrContinuationReview(runDir, run, binding) {
|
|
2061
|
+
if (!isRecord(binding) || !stringValue(binding.ref) || !stringValue(binding.hash)) throw new Error("retry exhaustion requires continuation_review ref/hash");
|
|
2062
|
+
const resolved = resolveReviewRef(runDir, binding.ref);
|
|
2063
|
+
const actualHash = hashFile(resolved.path, { mode: "raw" });
|
|
2064
|
+
if (actualHash !== binding.hash) throw new Error("post-PR continuation review exact-byte hash mismatch");
|
|
2065
|
+
const review = parseJsonObjectFile(resolved.path, "post-PR continuation review");
|
|
2066
|
+
const remediation = run.post_pr.remediation;
|
|
2067
|
+
const latestFailure = run.post_pr.evidence_refs?.at(-1) || { ref: remediation.failure_evidence_ref, hash: remediation.failure_evidence_hash };
|
|
2068
|
+
const failurePath = resolveEvidenceRef(runDir, latestFailure.ref).path;
|
|
2069
|
+
if (hashFile(failurePath, { mode: "raw" }) !== latestFailure.hash) throw new Error("latest post-PR failure evidence hash mismatch");
|
|
2070
|
+
const failure = parseJsonObjectFile(failurePath, "latest post-PR failure evidence");
|
|
2071
|
+
if (!/^[0-9a-f]{40}$/u.test(failure.failed_head_sha || "")) throw new Error("latest post-PR failure evidence head is invalid");
|
|
2072
|
+
const postPrForHash = cloneJson(run.post_pr);
|
|
2073
|
+
delete postPrForHash.continuation_review;
|
|
2074
|
+
const pr = githubPrUrlParts(run.pr_url);
|
|
2075
|
+
const expected = {
|
|
2076
|
+
kind: "post-pr-continuation",
|
|
2077
|
+
subject: run.run_id,
|
|
2078
|
+
verdict: "BLOCKED",
|
|
2079
|
+
attempt: run.post_pr.attempt,
|
|
2080
|
+
reason: "post-pr-retry-exhausted",
|
|
2081
|
+
route: remediation.route,
|
|
2082
|
+
evidence_ref: remediation.failure_evidence_ref,
|
|
2083
|
+
evidence_hash: remediation.failure_evidence_hash,
|
|
2084
|
+
post_pr_hash: hashValue(postPrForHash),
|
|
2085
|
+
pr_url: run.pr_url,
|
|
2086
|
+
repository: pr.repository,
|
|
2087
|
+
pr_number: pr.number,
|
|
2088
|
+
head_sha: failure.failed_head_sha,
|
|
2089
|
+
pr_disposition: "leave-unchanged",
|
|
2090
|
+
};
|
|
2091
|
+
for (const [key, value] of Object.entries(expected)) if (review[key] !== value) throw new Error(`post-PR continuation review ${key} mismatch`);
|
|
2092
|
+
if (!stringValue(review.summary) && !(Array.isArray(review.required_fixes) && review.required_fixes.some(stringValue))) throw new Error("post-PR continuation review requires summary or required_fixes");
|
|
2093
|
+
return { ref: binding.ref, hash: actualHash };
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function assertPostPrCandidateGitState(currentRun, nextPostPr, options = {}) {
|
|
2097
|
+
const remediation = nextPostPr.remediation;
|
|
2098
|
+
if (!isRecord(remediation) || !stringValue(remediation.candidate_head_sha)) return;
|
|
2099
|
+
if (!["committed", "revalidating", "validated", "push-pending", "remote-confirmed"].includes(remediation.stage)) return;
|
|
2100
|
+
const cwd = options.worktree || currentRun.worktree;
|
|
2101
|
+
if (!stringValue(cwd)) throw new Error("post-PR candidate verification requires run.worktree");
|
|
2102
|
+
const head = git(cwd, ["rev-parse", "--verify", "HEAD^{commit}"]);
|
|
2103
|
+
if (!head.ok || head.stdout.trim() !== remediation.candidate_head_sha) throw new Error("post-PR candidate head must equal local branch HEAD");
|
|
2104
|
+
const ancestor = git(cwd, ["merge-base", "--is-ancestor", remediation.baseline_head_sha, remediation.candidate_head_sha]);
|
|
2105
|
+
if (!ancestor.ok) throw new Error("post-PR candidate head must descend from baseline_head_sha");
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
function assertPostPrRefsConsistent(runDir, run) {
|
|
2109
|
+
const failed = postPrConsistencyChecks(runDir, run).filter((check) => !check.ok);
|
|
2110
|
+
if (failed.length) throw new Error(`post-PR ref/hash invariant failed: ${failed.flatMap((check) => check.errors).map((error) => error.message).join("; ")}`);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
function assertPostPrGenericMutation(current, next, hooks = {}) {
|
|
2114
|
+
if (hooks.postPr === true || hooks.prCreated === true) return;
|
|
2115
|
+
const persisted = isRecord(current.post_pr);
|
|
2116
|
+
const active = current.post_pr?.policy?.enabled === true && !["disabled", "awaiting-pr", "succeeded", "blocked", "needs-human"].includes(current.post_pr.phase);
|
|
2117
|
+
if (persisted && !sameJson(current.post_pr, next.post_pr)) throw new Error("persisted post-PR state can only be changed by checked post-PR transitions");
|
|
2118
|
+
for (const key of ["pr_url", "github_account", "max_retries"]) {
|
|
2119
|
+
if (persisted && current[key] !== next[key]) throw new Error(`persisted post-PR ${key} can only be changed by checked lifecycle transitions`);
|
|
2120
|
+
}
|
|
2121
|
+
if (active && current.status !== next.status) throw new Error("active post-PR runs can only terminalize through transitionPostPrTerminal");
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function assertTerminalTransition(current, next, hooks = {}) {
|
|
2125
|
+
if (TERMINAL_RUN_STATUSES.has(current.status)) throw new Error(`terminal run '${current.status}' cannot be mutated`);
|
|
2126
|
+
if (current.status === next.status) return;
|
|
2127
|
+
if (!TERMINAL_RUN_STATUSES.has(next.status)) return;
|
|
2128
|
+
if (hooks.prCreated === true || hooks.terminal === true || hooks.postPrTerminal === true) return;
|
|
2129
|
+
throw new Error(next.status === "completed" ? "completed terminal transitions must use transitionPrCreated" : "terminal transitions must use transitionTerminalResult");
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function assertPrCreatedPreconditions(run, request) {
|
|
2133
|
+
if (stringValue(run.pr_url)) throw new Error("pr-created requires run.pr_url to be unset");
|
|
2134
|
+
assertPrCreatedReadiness(request.runDir, run);
|
|
2135
|
+
assertPrNumberMatchesUrl(request.pr_url, request.pr_number);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function assertPrCreatedReadiness(runDir, run) {
|
|
2139
|
+
if (run.gates?.pre_pr?.status !== "approved") throw new Error("pr-created requires approved pre_pr gate");
|
|
2140
|
+
if (!PASSING_VALIDATOR_VERDICTS.has(run.validator?.verdict)) throw new Error("pr-created requires validator verdict GO or GO-WITH-NITS");
|
|
2141
|
+
if (!PASSING_SECURITY_VERDICTS.has(run.security_review?.verdict)) throw new Error("pr-created requires security_review verdict PASS");
|
|
2142
|
+
assertPrCreatedSliceState(run);
|
|
2143
|
+
assertPassingVerdictArtifacts(runDir, run);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function assertPrCreatedSliceState(run) {
|
|
2147
|
+
const slices = Array.isArray(run.slices) ? run.slices : [];
|
|
2148
|
+
const unfinished = slices.filter((slice) => slice?.status !== "merged" && slice?.status !== "blocked").map((slice) => slice?.id || "<unknown>");
|
|
2149
|
+
if (unfinished.length > 0) throw new Error(`pr-created requires all slices to be merged or blocked; unfinished slices: ${unfinished.join(", ")}`);
|
|
2150
|
+
if (!slices.some((slice) => slice?.status === "merged")) throw new Error("pr-created requires at least one merged slice");
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function assertPassingVerdictArtifacts(runDir, run) {
|
|
2154
|
+
if (!stringValue(runDir)) throw new Error("pr-created requires run directory context");
|
|
2155
|
+
if (!stringValue(run.validator?.report)) throw new Error("pr-created requires validator report ref");
|
|
2156
|
+
resolveArtifactRef(runDir, run.validator.report);
|
|
2157
|
+
const validatorReviewRef = stringValue(run.validator.review_ref) ? run.validator.review_ref : "reviews/implementation-validator.json";
|
|
2158
|
+
const validatorReview = resolveReviewRef(runDir, validatorReviewRef);
|
|
2159
|
+
const validatorJson = parseJsonObjectFile(validatorReview.path, "validator review_ref");
|
|
2160
|
+
if (!PASSING_VALIDATOR_VERDICTS.has(validatorJson.verdict)) throw new Error("pr-created requires validator review verdict GO or GO-WITH-NITS");
|
|
2161
|
+
if (validatorJson.verdict !== run.validator.verdict) throw new Error("pr-created requires validator review verdict to match run.validator.verdict");
|
|
2162
|
+
if (!stringValue(run.security_review?.review_ref)) throw new Error("pr-created requires security_review review_ref");
|
|
2163
|
+
const securityReview = resolveReviewRef(runDir, run.security_review.review_ref);
|
|
2164
|
+
const securityJson = parseJsonObjectFile(securityReview.path, "security_review.review_ref");
|
|
2165
|
+
if (securityJson.verdict !== "PASS") throw new Error("pr-created requires security_review review verdict PASS");
|
|
2166
|
+
if (securityJson.verdict !== run.security_review.verdict) throw new Error("pr-created requires security review verdict to match run.security_review.verdict");
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function assertPrNumberMatchesUrl(prUrl, prNumber) {
|
|
2170
|
+
if (githubPrUrlParts(prUrl).number !== prNumber) throw new Error("pr-created requires pr_number to match the GitHub PR URL");
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function normalizePrCreatedInput(input) {
|
|
2174
|
+
if (!isRecord(input)) throw new Error("transitionPrCreated requires an input object");
|
|
2175
|
+
const prUrl = canonicalizeGithubPrUrl(firstNonEmptyString(input.pr_url, input.prUrl));
|
|
2176
|
+
const repository = requireNonEmptyString(input.repository, "repository");
|
|
2177
|
+
const parts = githubPrUrlParts(prUrl);
|
|
2178
|
+
if (repository !== parts.repository) throw new Error("pr-created requires repository to match the GitHub PR URL");
|
|
2179
|
+
return {
|
|
2180
|
+
...cloneJson(input),
|
|
2181
|
+
pr_url: prUrl,
|
|
2182
|
+
pr_number: normalizePrNumber(input.pr_number ?? input.prNumber),
|
|
2183
|
+
repository,
|
|
2184
|
+
draft: input.draft === undefined ? false : normalizeBoolean(input.draft, "draft"),
|
|
2185
|
+
head_sha: normalizeOptionalHeadSha(input.head_sha ?? input.headSha),
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
function normalizeOptionalHeadSha(value) {
|
|
2190
|
+
if (value === undefined || value === null || value === "") return null;
|
|
2191
|
+
const sha = String(value).trim();
|
|
2192
|
+
if (!/^[0-9a-f]{40}$/u.test(sha)) throw new Error("head_sha must be a full 40-character lowercase git SHA");
|
|
2193
|
+
return sha;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
function normalizeSliceMergedInput(input) {
|
|
2197
|
+
if (!isRecord(input)) throw new Error("transitionSliceMerged requires an input object");
|
|
2198
|
+
return { merge_commit: requireNonEmptyString(input.merge_commit ?? input.mergeCommit, "merge_commit") };
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
function assertSliceMergedPreconditions(runDir, sliceId, slice, options = {}) {
|
|
2202
|
+
if (!stringValue(slice.merge_commit)) throw new Error(`slice '${sliceId}' merge requires merge_commit`);
|
|
2203
|
+
const review = resolveReviewRef(runDir, requireNonEmptyString(slice.review_ref, "review_ref"));
|
|
2204
|
+
const reviewJson = parseJsonObjectFile(review.path, `slice '${sliceId}' review_ref`);
|
|
2205
|
+
if (reviewJson.verdict !== "APPROVE") throw new Error(`slice '${sliceId}' merge requires APPROVE review`);
|
|
2206
|
+
if (reviewJson.subject !== sliceId) throw new Error(`slice '${sliceId}' review subject must match slice id`);
|
|
2207
|
+
resolveEvidenceRef(runDir, requireNonEmptyString(slice.evidence_ref, "evidence_ref"));
|
|
2208
|
+
const branch = requireNonEmptyString(slice.branch, "branch");
|
|
2209
|
+
const branchResult = git(options.repoRoot || runDir, ["rev-parse", "--verify", `refs/heads/${branch}`]);
|
|
2210
|
+
if (!branchResult.ok) throw new Error(`slice '${sliceId}' merge requires existing branch '${branch}'`);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function assertSliceReviewPreconditions(runDir, sliceId, slice) {
|
|
2214
|
+
if (slice.status !== "review") return;
|
|
2215
|
+
const evidence = resolveEvidenceRef(runDir, requireNonEmptyString(slice.evidence_ref, "evidence_ref"));
|
|
2216
|
+
const evidenceJson = parseJsonObjectFile(evidence.path, `slice '${sliceId}' evidence_ref`);
|
|
2217
|
+
if (stringValue(evidenceJson.subject) && evidenceJson.subject !== sliceId) throw new Error(`slice '${sliceId}' evidence subject must match slice id`);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function normalizePrCreatedTerminalResult(run, request) {
|
|
2221
|
+
const terminalResult = isRecord(request.terminal_result) ? cloneJson(request.terminal_result) : {};
|
|
2222
|
+
return {
|
|
2223
|
+
...terminalResult,
|
|
2224
|
+
status: "completed",
|
|
2225
|
+
run_id: run.run_id,
|
|
2226
|
+
pr_url: request.pr_url,
|
|
2227
|
+
pr_number: request.pr_number,
|
|
2228
|
+
repository: request.repository,
|
|
2229
|
+
draft: request.draft,
|
|
2230
|
+
reason: terminalResult.reason ?? null,
|
|
2231
|
+
summary: terminalResult.summary ?? (request.draft ? "Draft PR created." : "PR created."),
|
|
2232
|
+
artifacts: isRecord(terminalResult.artifacts) ? terminalResult.artifacts : {},
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
function normalizeTerminalResult(terminalResult) {
|
|
2237
|
+
if (typeof terminalResult === "string") return { status: terminalResult };
|
|
2238
|
+
if (!isRecord(terminalResult)) throw new Error("transitionTerminalResult requires a terminal result object");
|
|
2239
|
+
return cloneJson(terminalResult);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
export function normalizePrNumber(value) {
|
|
2243
|
+
const number = typeof value === "string" && value.trim() !== "" ? Number(value.trim()) : value;
|
|
2244
|
+
if (!Number.isInteger(number) || number < 1 || String(number) !== String(value).trim()) throw new Error("transitionPrCreated requires pr_number");
|
|
2245
|
+
return number;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function normalizeBoolean(value, label) {
|
|
2249
|
+
if (typeof value !== "boolean") throw new Error(`transitionPrCreated requires boolean ${label}`);
|
|
2250
|
+
return value;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
function normalizeGateMap(gates) {
|
|
2254
|
+
return isRecord(gates) ? gates : {};
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function assertSafeGateName(gateName) {
|
|
2258
|
+
if (!SAFE_GATE_NAME_PATTERN.test(gateName)) throw new Error(`invalid gate name '${gateName}': must match safe pattern [a-z0-9][a-z0-9_-]*[a-z0-9]`);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
function assertCollectionUpdater(updater, label) {
|
|
2262
|
+
if (typeof updater === "function" || isRecord(updater)) return;
|
|
2263
|
+
throw new Error(`${label} requires an updater function or object`);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
async function applyCollectionItemUpdate({ items, selector, updater, selectorLabel, seed, identityKey }) {
|
|
2267
|
+
const index = selectCollectionItemIndex(items, selector, selectorLabel, identityKey);
|
|
2268
|
+
const hasExisting = index >= 0;
|
|
2269
|
+
const original = hasExisting ? items[index] : undefined;
|
|
2270
|
+
const base = hasExisting ? cloneJson(original) : cloneJson(seed);
|
|
2271
|
+
let nextValue;
|
|
2272
|
+
if (typeof updater === "function") nextValue = await updater(base, { current: hasExisting ? cloneJson(original) : null, index, selector });
|
|
2273
|
+
else nextValue = updater;
|
|
2274
|
+
if (nextValue === undefined) {
|
|
2275
|
+
if (hasExisting) {
|
|
2276
|
+
if (sameJson(original, base)) return { changed: false, index };
|
|
2277
|
+
items[index] = base;
|
|
2278
|
+
return { changed: true, index };
|
|
2279
|
+
}
|
|
2280
|
+
if (sameJson(seed, base)) return { changed: false, index: -1 };
|
|
2281
|
+
items.push(base);
|
|
2282
|
+
return { changed: true, index: items.length - 1 };
|
|
2283
|
+
}
|
|
2284
|
+
const replacement = isRecord(base) && isRecord(nextValue) ? { ...base, ...cloneJson(nextValue) } : cloneJson(nextValue);
|
|
2285
|
+
if (hasExisting) {
|
|
2286
|
+
if (sameJson(original, replacement)) return { changed: false, index };
|
|
2287
|
+
items[index] = replacement;
|
|
2288
|
+
return { changed: true, index };
|
|
2289
|
+
}
|
|
2290
|
+
if (sameJson(seed, replacement)) return { changed: false, index: -1 };
|
|
2291
|
+
items.push(replacement);
|
|
2292
|
+
return { changed: true, index: items.length - 1 };
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function selectCollectionItemIndex(items, selector, selectorLabel, identityKey) {
|
|
2296
|
+
if (typeof selector === "function") return items.findIndex((item, index) => selector(item, index));
|
|
2297
|
+
if (Number.isInteger(selector)) {
|
|
2298
|
+
if (selector < 0) throw new Error(`${selectorLabel} index must be non-negative`);
|
|
2299
|
+
return selector < items.length ? selector : -1;
|
|
2300
|
+
}
|
|
2301
|
+
if (stringValue(selector)) return items.findIndex((item) => item?.[identityKey] === selector);
|
|
2302
|
+
if (isRecord(selector)) {
|
|
2303
|
+
if (Number.isInteger(selector.index)) {
|
|
2304
|
+
if (selector.index < 0) throw new Error(`${selectorLabel} index must be non-negative`);
|
|
2305
|
+
return selector.index < items.length ? selector.index : -1;
|
|
2306
|
+
}
|
|
2307
|
+
const entries = Object.entries(selector);
|
|
2308
|
+
if (entries.length === 0) return -1;
|
|
2309
|
+
return items.findIndex((item) => entries.every(([key, value]) => item?.[key] === value));
|
|
2310
|
+
}
|
|
2311
|
+
throw new Error(`invalid ${selectorLabel}`);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function collectionHasItem(items, selector, identityKey) {
|
|
2315
|
+
return selectCollectionItemIndex(items, selector, "collection selector", identityKey) >= 0;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
function formatSelector(selector) {
|
|
2319
|
+
if (stringValue(selector)) return selector;
|
|
2320
|
+
if (isRecord(selector) && stringValue(selector.id)) return selector.id;
|
|
2321
|
+
if (isRecord(selector) && stringValue(selector.agent)) return selector.agent;
|
|
2322
|
+
return JSON.stringify(selector);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
function seedRunStep(stepSelector) {
|
|
2326
|
+
if (stringValue(stepSelector)) return { agent: stepSelector };
|
|
2327
|
+
if (isRecord(stepSelector) && stringValue(stepSelector.agent)) return { agent: stepSelector.agent };
|
|
2328
|
+
return {};
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
function seedRunSlice(sliceSelector) {
|
|
2332
|
+
if (stringValue(sliceSelector)) return { id: sliceSelector };
|
|
2333
|
+
if (isRecord(sliceSelector) && stringValue(sliceSelector.id)) return { id: sliceSelector.id };
|
|
2334
|
+
return {};
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
async function readJson(path) {
|
|
2338
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
async function writeJsonAtomically(path, value) {
|
|
2342
|
+
await writeTextAtomically(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
async function writeTextAtomically(path, contents) {
|
|
2346
|
+
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
2347
|
+
try {
|
|
2348
|
+
await writeFile(tempPath, contents, "utf8");
|
|
2349
|
+
await rename(tempPath, path);
|
|
2350
|
+
} finally {
|
|
2351
|
+
if (existsSync(tempPath)) await rm(tempPath, { force: true });
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
function formatLockTimeout(lockDir, owner) {
|
|
2356
|
+
if (!isRecord(owner)) return `timed out waiting for run.json lock at ${lockDir}`;
|
|
2357
|
+
const heldBy = [owner.hostname, owner.pid].filter((value) => value !== undefined && value !== null).join(":");
|
|
2358
|
+
const suffix = heldBy ? ` held by ${heldBy}` : "";
|
|
2359
|
+
const acquiredAt = stringValue(owner.acquired_at) ? ` since ${owner.acquired_at}` : "";
|
|
2360
|
+
return `timed out waiting for run.json lock at ${lockDir}${suffix}${acquiredAt}`;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
function formatErrorItems(errors) {
|
|
2364
|
+
return errors.map((error) => `${error.path}: ${error.message}`).join("; ");
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function firstNonEmptyString(...values) {
|
|
2368
|
+
for (const value of values) if (stringValue(value)) return String(value).trim();
|
|
2369
|
+
return null;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function parseJsonFile(path, label) {
|
|
2373
|
+
try {
|
|
2374
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
2375
|
+
} catch (error) {
|
|
2376
|
+
throw new Error(`${label} must be valid JSON: ${error.message}`);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
function parseJsonObjectFile(path, label) {
|
|
2381
|
+
const value = parseJsonFile(path, label);
|
|
2382
|
+
if (!isRecord(value)) throw new Error(`${label} must be a JSON object`);
|
|
2383
|
+
return value;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function normalizePositiveInteger(value, fallback) {
|
|
2387
|
+
if (value === undefined || value === null) return fallback;
|
|
2388
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error("lock timing options must be positive integers");
|
|
2389
|
+
return value;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function cloneJson(value) {
|
|
2393
|
+
// JSON cloning intentionally treats undefined as absent; use explicit null to clear fields.
|
|
2394
|
+
return JSON.parse(JSON.stringify(value));
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
function sameJson(left, right) {
|
|
2398
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function isRecord(value) {
|
|
2402
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function stringValue(value) {
|
|
2406
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function delay(ms) {
|
|
2410
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2411
|
+
}
|