@khorsheed/dsh-ankh-guard 0.1.1 → 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/CHANGELOG.md +24 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2349 -182
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +721 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1421 -166
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +157 -39
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +106 -0
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +30 -11
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
import { f as stateFile } from "./state-CZMypGkB.js";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
//#region lib/types/git.js
|
|
8
|
+
/**
|
|
9
|
+
* Git helpers for the self-restart guard: the credential binds to the current
|
|
10
|
+
* HEAD, checkpoints are real commits, and rollback is a hard reset. All calls
|
|
11
|
+
* are synchronous child-process invocations scoped to the repo directory.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The repository's current HEAD, or null when the directory is not inside a
|
|
15
|
+
* git repository (or git itself is unavailable).
|
|
16
|
+
* @param repoDir - repository directory.
|
|
17
|
+
* @returns the full HEAD sha, or null.
|
|
18
|
+
*/
|
|
19
|
+
function currentHead(repoDir) {
|
|
20
|
+
try {
|
|
21
|
+
const out = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
22
|
+
cwd: repoDir,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
stdio: "pipe"
|
|
25
|
+
}).trim();
|
|
26
|
+
return out.length > 0 ? out : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Porcelain entries for every tracked or untracked working-tree change.
|
|
33
|
+
* `null` means git could not inspect the checkout; callers must fail closed.
|
|
34
|
+
*/
|
|
35
|
+
function workingTreeChanges(repoDir) {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync("git", [
|
|
38
|
+
"status",
|
|
39
|
+
"--porcelain=v1",
|
|
40
|
+
"-z",
|
|
41
|
+
"--untracked-files=all"
|
|
42
|
+
], {
|
|
43
|
+
cwd: repoDir,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: "pipe"
|
|
46
|
+
}).split("\0").filter((entry) => entry !== "");
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Whether the checkout has no staged, unstaged, or untracked changes. */
|
|
52
|
+
function isWorkingTreeClean(repoDir) {
|
|
53
|
+
const changes = workingTreeChanges(repoDir);
|
|
54
|
+
return changes !== null && changes.length === 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Record a clean HEAD directly, or commit the whole dirty working tree as an
|
|
58
|
+
* explicitly approved checkpoint snapshot. Dirty commits use a temporary git
|
|
59
|
+
* index: a hook/commit failure leaves the caller's real index untouched.
|
|
60
|
+
* @param repoDir - repository directory.
|
|
61
|
+
* @param message - checkpoint commit message.
|
|
62
|
+
* @param artifactPattern - staged paths matching this are reported as
|
|
63
|
+
* build-artifact-looking warnings (deployment-specific — see
|
|
64
|
+
* SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
|
|
65
|
+
* @returns the new HEAD sha, or a failure reason.
|
|
66
|
+
*/
|
|
67
|
+
function commitCheckpoint(repoDir, message, artifactPattern, includeDirty = false) {
|
|
68
|
+
let tempDir;
|
|
69
|
+
try {
|
|
70
|
+
const changes = workingTreeChanges(repoDir);
|
|
71
|
+
if (changes === null) return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: "git checkpoint failed: working tree status is unavailable"
|
|
74
|
+
};
|
|
75
|
+
if (changes.length > 0 && !includeDirty) {
|
|
76
|
+
const shown = changes.slice(0, 10).map((entry) => ` ${entry}`).join("\n");
|
|
77
|
+
const more = changes.length > 10 ? `\n … (${changes.length - 10} more)` : "";
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: `checkpoint refused: working tree has ${changes.length} change(s); review them, then rerun with --include-dirty to commit the complete snapshot:\n${shown}${more}`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const previousHead = currentHead(repoDir);
|
|
84
|
+
if (previousHead === null) return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: "git checkpoint failed: current HEAD is unavailable"
|
|
87
|
+
};
|
|
88
|
+
if (changes.length === 0) return {
|
|
89
|
+
ok: true,
|
|
90
|
+
sha: previousHead,
|
|
91
|
+
artifacts: [],
|
|
92
|
+
createdCommit: false
|
|
93
|
+
};
|
|
94
|
+
const indexPathRaw = execFileSync("git", [
|
|
95
|
+
"rev-parse",
|
|
96
|
+
"--git-path",
|
|
97
|
+
"index"
|
|
98
|
+
], {
|
|
99
|
+
cwd: repoDir,
|
|
100
|
+
encoding: "utf8",
|
|
101
|
+
stdio: "pipe"
|
|
102
|
+
}).trim();
|
|
103
|
+
const indexPath = resolve(repoDir, indexPathRaw);
|
|
104
|
+
tempDir = mkdtempSync(join(tmpdir(), "ankh-guard-index-"));
|
|
105
|
+
const tempIndex = join(tempDir, "index");
|
|
106
|
+
copyFileSync(indexPath, tempIndex);
|
|
107
|
+
const env = {
|
|
108
|
+
...process.env,
|
|
109
|
+
GIT_INDEX_FILE: tempIndex
|
|
110
|
+
};
|
|
111
|
+
execFileSync("git", ["add", "-A"], {
|
|
112
|
+
cwd: repoDir,
|
|
113
|
+
env,
|
|
114
|
+
stdio: "pipe"
|
|
115
|
+
});
|
|
116
|
+
const staged = execFileSync("git", [
|
|
117
|
+
"diff",
|
|
118
|
+
"--cached",
|
|
119
|
+
"--name-only"
|
|
120
|
+
], {
|
|
121
|
+
cwd: repoDir,
|
|
122
|
+
env,
|
|
123
|
+
encoding: "utf8"
|
|
124
|
+
});
|
|
125
|
+
const artifacts = artifactPattern === void 0 ? [] : staged.split("\n").filter((file) => artifactPattern.test(file));
|
|
126
|
+
execFileSync("git", [
|
|
127
|
+
"commit",
|
|
128
|
+
"-m",
|
|
129
|
+
message
|
|
130
|
+
], {
|
|
131
|
+
cwd: repoDir,
|
|
132
|
+
env,
|
|
133
|
+
stdio: "pipe"
|
|
134
|
+
});
|
|
135
|
+
const sha = currentHead(repoDir);
|
|
136
|
+
if (sha === null) return {
|
|
137
|
+
ok: false,
|
|
138
|
+
error: "checkpoint commit succeeded but HEAD became unreadable"
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
execFileSync("git", [
|
|
142
|
+
"reset",
|
|
143
|
+
"--mixed",
|
|
144
|
+
"HEAD"
|
|
145
|
+
], {
|
|
146
|
+
cwd: repoDir,
|
|
147
|
+
stdio: "pipe"
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
try {
|
|
151
|
+
execFileSync("git", [
|
|
152
|
+
"update-ref",
|
|
153
|
+
"HEAD",
|
|
154
|
+
previousHead,
|
|
155
|
+
sha
|
|
156
|
+
], {
|
|
157
|
+
cwd: repoDir,
|
|
158
|
+
stdio: "pipe"
|
|
159
|
+
});
|
|
160
|
+
} catch {}
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: `checkpoint commit could not settle the real index: ${String(error)}`
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
sha,
|
|
169
|
+
artifacts,
|
|
170
|
+
createdCommit: true
|
|
171
|
+
};
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: `git checkpoint failed: ${String(error)}`
|
|
176
|
+
};
|
|
177
|
+
} finally {
|
|
178
|
+
if (tempDir !== void 0) rmSync(tempDir, {
|
|
179
|
+
recursive: true,
|
|
180
|
+
force: true
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Roll the checkout back to a checkpoint commit WITHOUT losing work: the
|
|
186
|
+
* discarded HEAD becomes a `guard-backup-*` branch, and uncommitted tracked
|
|
187
|
+
* changes become a second `-wip` anchor commit (`git stash create` snapshots
|
|
188
|
+
* the worktree without touching it; untracked files survive `reset --hard`
|
|
189
|
+
* on their own). Every reset path — the watchdog, the CLI, the agent-facing
|
|
190
|
+
* service — funnels through here, so recovery never depends on the reflog.
|
|
191
|
+
* @param repoDir - repository directory.
|
|
192
|
+
* @param sha - the checkpoint commit to reset to.
|
|
193
|
+
* @returns success with the recovery anchor refs, or a failure reason.
|
|
194
|
+
*/
|
|
195
|
+
function resetToCheckpoint(repoDir, sha) {
|
|
196
|
+
const anchors = [];
|
|
197
|
+
try {
|
|
198
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").replace(/\..*$/, "");
|
|
199
|
+
const anchor = `guard-backup-${stamp.slice(0, 8)}-${stamp.slice(8)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
200
|
+
const head = currentHead(repoDir);
|
|
201
|
+
if (head !== null && head !== sha) try {
|
|
202
|
+
execFileSync("git", [
|
|
203
|
+
"branch",
|
|
204
|
+
anchor,
|
|
205
|
+
"HEAD"
|
|
206
|
+
], {
|
|
207
|
+
cwd: repoDir,
|
|
208
|
+
stdio: "pipe"
|
|
209
|
+
});
|
|
210
|
+
anchors.push(anchor);
|
|
211
|
+
} catch {}
|
|
212
|
+
let wip = "";
|
|
213
|
+
try {
|
|
214
|
+
wip = execFileSync("git", ["stash", "create"], {
|
|
215
|
+
cwd: repoDir,
|
|
216
|
+
encoding: "utf8"
|
|
217
|
+
}).trim();
|
|
218
|
+
} catch {}
|
|
219
|
+
if (wip !== "") try {
|
|
220
|
+
execFileSync("git", [
|
|
221
|
+
"branch",
|
|
222
|
+
`${anchor}-wip`,
|
|
223
|
+
wip
|
|
224
|
+
], {
|
|
225
|
+
cwd: repoDir,
|
|
226
|
+
stdio: "pipe"
|
|
227
|
+
});
|
|
228
|
+
anchors.push(`${anchor}-wip`);
|
|
229
|
+
} catch {}
|
|
230
|
+
execFileSync("git", [
|
|
231
|
+
"reset",
|
|
232
|
+
"--hard",
|
|
233
|
+
sha
|
|
234
|
+
], {
|
|
235
|
+
cwd: repoDir,
|
|
236
|
+
stdio: "pipe"
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
anchors
|
|
241
|
+
};
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
error: `git reset --hard ${sha} failed: ${String(error)}`,
|
|
246
|
+
anchors
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region lib/types/launch-spec.js
|
|
252
|
+
/**
|
|
253
|
+
* Durable launch-configuration cutovers.
|
|
254
|
+
*
|
|
255
|
+
* A launch change is not a repository rollback: the command, home, credential
|
|
256
|
+
* repository, host root, profile, port, readiness handoff, and recovery choice
|
|
257
|
+
* move as one unit. The selected side lives in launch-spec.json (one atomic
|
|
258
|
+
* write); the redacted,
|
|
259
|
+
* append-only-ish operational receipt lives in launch-cutover.json. The full
|
|
260
|
+
* commands never enter the receipt because a launch command may carry secret
|
|
261
|
+
* environment values.
|
|
262
|
+
*/
|
|
263
|
+
function atomicWriteJson(file, value) {
|
|
264
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
265
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
266
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
|
|
267
|
+
renameSync(tmp, file);
|
|
268
|
+
try {
|
|
269
|
+
chmodSync(file, 384);
|
|
270
|
+
} catch {}
|
|
271
|
+
}
|
|
272
|
+
function isLaunchSpec(value) {
|
|
273
|
+
if (typeof value !== "object" || value === null) return false;
|
|
274
|
+
const spec = value;
|
|
275
|
+
return spec.version === 1 && typeof spec.command === "string" && spec.command !== "" && Number.isInteger(spec.port) && (spec.port ?? 0) > 0 && (spec.port ?? 0) <= 65535 && typeof spec.home === "string" && spec.home !== "" && typeof spec.credentialRepo === "string" && spec.credentialRepo !== "" && typeof spec.harnessRoot === "string" && spec.harnessRoot !== "" && typeof spec.profile === "string" && spec.profile !== "" && (spec.preflight === void 0 || isLaunchPreflightSpec(spec.preflight));
|
|
276
|
+
}
|
|
277
|
+
function isLaunchPreflightSpec(value) {
|
|
278
|
+
if (typeof value !== "object" || value === null) return false;
|
|
279
|
+
const spec = value;
|
|
280
|
+
return spec.version === 1 && (spec.surface === "source" || spec.surface === "built") && typeof spec.runnerExecutable === "string" && spec.runnerExecutable !== "" && Array.isArray(spec.runnerRuntimeArgs) && spec.runnerRuntimeArgs.every((arg) => typeof arg === "string") && typeof spec.runnerPath === "string" && spec.runnerPath !== "" && typeof spec.runnerSha256 === "string" && /^[a-f0-9]{64}$/.test(spec.runnerSha256) && typeof spec.installAnchor === "string" && spec.installAnchor !== "" && typeof spec.installAnchorSha256 === "string" && /^[a-f0-9]{64}$/.test(spec.installAnchorSha256) && typeof spec.hostPackageVersion === "string" && spec.hostPackageVersion !== "" && typeof spec.targetCommandSha256 === "string" && /^[a-f0-9]{64}$/.test(spec.targetCommandSha256) && (spec.candidateProbeCommand === void 0 ? spec.candidateProbeSha256 === void 0 && spec.candidateProbeProvenance === void 0 : typeof spec.candidateProbeCommand === "string" && spec.candidateProbeCommand !== "" && typeof spec.candidateProbeSha256 === "string" && /^[a-f0-9]{64}$/.test(spec.candidateProbeSha256) && (spec.candidateProbeProvenance === void 0 || spec.candidateProbeProvenance === "caller-supplied"));
|
|
281
|
+
}
|
|
282
|
+
function commandSha256(command) {
|
|
283
|
+
return createHash("sha256").update(command).digest("hex");
|
|
284
|
+
}
|
|
285
|
+
function isTransitionReference(value) {
|
|
286
|
+
if (typeof value !== "object" || value === null) return false;
|
|
287
|
+
const reference = value;
|
|
288
|
+
return reference.version === 1 && typeof reference.planPath === "string" && reference.planPath !== "" && typeof reference.planSha256 === "string" && /^[a-f0-9]{64}$/.test(reference.planSha256) && Number.isInteger(reference.operationCount) && (reference.operationCount ?? 0) > 0;
|
|
289
|
+
}
|
|
290
|
+
/** Read the selected launch state, or null for a deployment predating this protocol. */
|
|
291
|
+
function readLaunchState(stateDir) {
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(readFileSync(stateFile(stateDir, "launchSpec"), "utf8"));
|
|
294
|
+
if (parsed.version !== 1) return null;
|
|
295
|
+
if (parsed.mode === "stable" && isLaunchSpec(parsed.active)) return parsed;
|
|
296
|
+
if (parsed.mode === "cutover" && typeof parsed.cutoverId === "string" && (parsed.selected === "target" || parsed.selected === "previous") && isLaunchSpec(parsed.previous) && isLaunchSpec(parsed.target) && (parsed.transition === void 0 || isTransitionReference(parsed.transition))) return parsed;
|
|
297
|
+
return null;
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** The exact spec a newly starting supervisor must honor. */
|
|
303
|
+
function selectedLaunchSpec(state) {
|
|
304
|
+
if (state.mode === "stable") return state.active;
|
|
305
|
+
return state[state.selected];
|
|
306
|
+
}
|
|
307
|
+
/** Persist an ordinary, non-transactional active spec. */
|
|
308
|
+
function writeStableLaunchSpec(stateDir, spec, ifAbsent = false) {
|
|
309
|
+
if (!isLaunchSpec(spec)) throw new Error("invalid launch specification");
|
|
310
|
+
if (ifAbsent && readLaunchState(stateDir) !== null) return false;
|
|
311
|
+
atomicWriteJson(stateFile(stateDir, "launchSpec"), {
|
|
312
|
+
version: 1,
|
|
313
|
+
mode: "stable",
|
|
314
|
+
active: spec
|
|
315
|
+
});
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
function summarizeLaunchSpec(spec) {
|
|
319
|
+
return {
|
|
320
|
+
commandSha256: commandSha256(spec.command).slice(0, 16),
|
|
321
|
+
port: spec.port,
|
|
322
|
+
home: spec.home,
|
|
323
|
+
credentialRepo: spec.credentialRepo,
|
|
324
|
+
harnessRoot: spec.harnessRoot,
|
|
325
|
+
profile: spec.profile,
|
|
326
|
+
...spec.preflight === void 0 ? {} : { preflight: {
|
|
327
|
+
surface: spec.preflight.surface,
|
|
328
|
+
runnerExecutable: spec.preflight.runnerExecutable,
|
|
329
|
+
runnerRuntimeArgs: spec.preflight.runnerRuntimeArgs,
|
|
330
|
+
runnerPath: spec.preflight.runnerPath,
|
|
331
|
+
runnerSha256: spec.preflight.runnerSha256,
|
|
332
|
+
installAnchor: spec.preflight.installAnchor,
|
|
333
|
+
installAnchorSha256: spec.preflight.installAnchorSha256,
|
|
334
|
+
hostPackageVersion: spec.preflight.hostPackageVersion,
|
|
335
|
+
targetCommandSha256: spec.preflight.targetCommandSha256,
|
|
336
|
+
...spec.preflight.candidateProbeSha256 === void 0 ? {} : {
|
|
337
|
+
candidateProbeSha256: spec.preflight.candidateProbeSha256,
|
|
338
|
+
candidateProbeProvenance: "caller-supplied"
|
|
339
|
+
}
|
|
340
|
+
} }
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/** Safe operator view: preserve topology and selection without printing commands. */
|
|
344
|
+
function summarizeLaunchState(state) {
|
|
345
|
+
if (state === null) return null;
|
|
346
|
+
if (state.mode === "stable") return {
|
|
347
|
+
version: state.version,
|
|
348
|
+
mode: state.mode,
|
|
349
|
+
active: summarizeLaunchSpec(state.active)
|
|
350
|
+
};
|
|
351
|
+
return {
|
|
352
|
+
version: state.version,
|
|
353
|
+
mode: state.mode,
|
|
354
|
+
cutoverId: state.cutoverId,
|
|
355
|
+
selected: state.selected,
|
|
356
|
+
previous: summarizeLaunchSpec(state.previous),
|
|
357
|
+
target: summarizeLaunchSpec(state.target),
|
|
358
|
+
...state.transition === void 0 ? {} : { transition: {
|
|
359
|
+
planSha256: state.transition.planSha256,
|
|
360
|
+
operationCount: state.transition.operationCount
|
|
361
|
+
} }
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/** Read the durable receipt, or null when absent/malformed. */
|
|
365
|
+
function readCutoverReceipt(stateDir) {
|
|
366
|
+
try {
|
|
367
|
+
const receipt = JSON.parse(readFileSync(stateFile(stateDir, "launchCutover"), "utf8"));
|
|
368
|
+
if (receipt.version !== 1 || typeof receipt.id !== "string" || !Array.isArray(receipt.events)) return null;
|
|
369
|
+
if (receipt.browserHandoff === void 0) {
|
|
370
|
+
const legacy = receipt.authentication?.browserHandoff;
|
|
371
|
+
const status = legacy === "off" ? "off" : legacy === "not-required" ? "not-required" : legacy === "failed" ? "failed" : legacy === "accepted" ? "fallback-opened" : "pending";
|
|
372
|
+
receipt.browserHandoff = {
|
|
373
|
+
required: status !== "off",
|
|
374
|
+
status
|
|
375
|
+
};
|
|
376
|
+
if (legacy === "accepted") receipt.authentication.browserHandoff = status;
|
|
377
|
+
}
|
|
378
|
+
if (receipt.readiness?.role === "target") {
|
|
379
|
+
receipt.targetValidation ??= {};
|
|
380
|
+
receipt.targetValidation.readiness ??= receipt.readiness;
|
|
381
|
+
if (receipt.targetValidation.canary === void 0 && receipt.canary !== void 0) receipt.targetValidation.canary = receipt.canary;
|
|
382
|
+
} else if (receipt.readiness?.role === "previous") {
|
|
383
|
+
receipt.recovery.validation ??= {};
|
|
384
|
+
receipt.recovery.validation.readiness ??= receipt.readiness;
|
|
385
|
+
if (receipt.recovery.validation.canary === void 0 && receipt.canary !== void 0) {
|
|
386
|
+
receipt.recovery.validation.canary = receipt.canary;
|
|
387
|
+
delete receipt.canary;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return receipt;
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Persist an operator control request for the current watchdog to consume. */
|
|
396
|
+
function writeCutoverControl(stateDir, cutoverId, action, now) {
|
|
397
|
+
const active = activeCutover(stateDir);
|
|
398
|
+
if (active === null || active.receipt.id !== cutoverId) throw new Error(`cutover ${cutoverId} is not active`);
|
|
399
|
+
const request = {
|
|
400
|
+
version: 1,
|
|
401
|
+
cutoverId,
|
|
402
|
+
action,
|
|
403
|
+
requestedAt: now
|
|
404
|
+
};
|
|
405
|
+
if (action === "restore-previous") atomicWriteJson(stateFile(stateDir, "cutoverRestorePrevious"), request);
|
|
406
|
+
else {
|
|
407
|
+
const existing = readControlFile(stateFile(stateDir, "cutoverRestorePrevious"));
|
|
408
|
+
if (existing?.cutoverId === cutoverId) return existing;
|
|
409
|
+
atomicWriteJson(stateFile(stateDir, "cutoverAbort"), request);
|
|
410
|
+
}
|
|
411
|
+
const effective = readCutoverControl(stateDir);
|
|
412
|
+
return effective?.cutoverId === cutoverId ? effective : request;
|
|
413
|
+
}
|
|
414
|
+
function readControlFile(file) {
|
|
415
|
+
try {
|
|
416
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
417
|
+
if (value.version !== 1 || typeof value.cutoverId !== "string" || value.action !== "abort" && value.action !== "restore-previous" || typeof value.requestedAt !== "number") return null;
|
|
418
|
+
return value;
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function readCutoverControl(stateDir) {
|
|
424
|
+
const restore = readControlFile(stateFile(stateDir, "cutoverRestorePrevious"));
|
|
425
|
+
if (restore?.action === "restore-previous") return restore;
|
|
426
|
+
const legacy = readControlFile(stateFile(stateDir, "cutoverControl"));
|
|
427
|
+
if (legacy?.action === "restore-previous") return legacy;
|
|
428
|
+
return readControlFile(stateFile(stateDir, "cutoverAbort")) ?? legacy;
|
|
429
|
+
}
|
|
430
|
+
function clearCutoverControl(stateDir, cutoverId) {
|
|
431
|
+
for (const role of [
|
|
432
|
+
"cutoverRestorePrevious",
|
|
433
|
+
"cutoverAbort",
|
|
434
|
+
"cutoverControl"
|
|
435
|
+
]) {
|
|
436
|
+
const file = stateFile(stateDir, role);
|
|
437
|
+
if (readControlFile(file)?.cutoverId === cutoverId) rmSync(file, { force: true });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Prepare one cutover. The receipt lands first; the single launch-state rename
|
|
442
|
+
* is the commit point selecting target. A crash before that rename leaves the
|
|
443
|
+
* previous stable state authoritative and the old supervisor untouched.
|
|
444
|
+
*/
|
|
445
|
+
function prepareLaunchCutover(stateDir, input) {
|
|
446
|
+
if (!isLaunchSpec(input.previous) || !isLaunchSpec(input.target)) throw new Error("invalid launch specification");
|
|
447
|
+
if (input.id === "") throw new Error("cutover id is required");
|
|
448
|
+
if (!Number.isInteger(input.previousSupervisorPid) || input.previousSupervisorPid <= 0) throw new Error("invalid previous supervisor pid");
|
|
449
|
+
if (input.previousSupervisorStartToken === "") throw new Error("previous supervisor start identity is required");
|
|
450
|
+
for (const [label, value] of Object.entries({
|
|
451
|
+
previousChildPid: input.previousOwnership.childPid,
|
|
452
|
+
previousListenerPid: input.previousOwnership.listenerPid
|
|
453
|
+
})) if (!Number.isInteger(value) || value <= 0) throw new Error(`invalid ${label}`);
|
|
454
|
+
if (input.previousOwnership.childStartToken === "" || input.previousOwnership.listenerStartToken === "") throw new Error("previous child/listener start identity is required");
|
|
455
|
+
if (input.previous.port !== input.target.port) throw new Error("online cutover requires previous and target to use the same port");
|
|
456
|
+
if (input.transition !== void 0) {
|
|
457
|
+
if (!isTransitionReference(input.transition)) throw new Error("invalid transition reference");
|
|
458
|
+
if (input.previous.home !== input.target.home) throw new Error("filesystem transition requires previous and target to share one home");
|
|
459
|
+
}
|
|
460
|
+
for (const role of [
|
|
461
|
+
"cutoverRestorePrevious",
|
|
462
|
+
"cutoverAbort",
|
|
463
|
+
"cutoverControl"
|
|
464
|
+
]) rmSync(stateFile(stateDir, role), { force: true });
|
|
465
|
+
rmSync(stateFile(stateDir, "browserHandoffRequest"), { force: true });
|
|
466
|
+
rmSync(stateFile(stateDir, "browserHandoffAck"), { force: true });
|
|
467
|
+
const receipt = {
|
|
468
|
+
version: 1,
|
|
469
|
+
id: input.id,
|
|
470
|
+
phase: "prepared",
|
|
471
|
+
preparedAt: input.now,
|
|
472
|
+
updatedAt: input.now,
|
|
473
|
+
...input.initiator !== void 0 && input.initiator !== "" ? { initiator: input.initiator } : {},
|
|
474
|
+
previous: summarizeLaunchSpec(input.previous),
|
|
475
|
+
target: summarizeLaunchSpec(input.target),
|
|
476
|
+
...input.transition === void 0 ? {} : { transition: {
|
|
477
|
+
planSha256: input.transition.planSha256,
|
|
478
|
+
operationCount: input.transition.operationCount,
|
|
479
|
+
phase: "pending"
|
|
480
|
+
} },
|
|
481
|
+
supervisor: {
|
|
482
|
+
previousPid: input.previousSupervisorPid,
|
|
483
|
+
previousStartToken: input.previousSupervisorStartToken
|
|
484
|
+
},
|
|
485
|
+
child: { previousPid: input.previousOwnership.childPid },
|
|
486
|
+
ownership: { previous: input.previousOwnership },
|
|
487
|
+
authentication: { browserHandoff: input.browserHandoff === "required" ? "pending" : "off" },
|
|
488
|
+
browserHandoff: {
|
|
489
|
+
required: input.browserHandoff === "required",
|
|
490
|
+
status: input.browserHandoff === "required" ? "pending" : "off"
|
|
491
|
+
},
|
|
492
|
+
...input.target.preflight?.candidateProbeSha256 === void 0 ? {} : { preflight: {
|
|
493
|
+
surface: input.target.preflight.surface,
|
|
494
|
+
runnerExecutable: input.target.preflight.runnerExecutable,
|
|
495
|
+
runnerRuntimeArgs: input.target.preflight.runnerRuntimeArgs,
|
|
496
|
+
runnerPath: input.target.preflight.runnerPath,
|
|
497
|
+
runnerSha256: input.target.preflight.runnerSha256,
|
|
498
|
+
installAnchor: input.target.preflight.installAnchor,
|
|
499
|
+
installAnchorSha256: input.target.preflight.installAnchorSha256,
|
|
500
|
+
hostPackageVersion: input.target.preflight.hostPackageVersion,
|
|
501
|
+
targetCommandSha256: input.target.preflight.targetCommandSha256,
|
|
502
|
+
candidateProbeSha256: input.target.preflight.candidateProbeSha256,
|
|
503
|
+
candidateProbeProvenance: "caller-supplied",
|
|
504
|
+
candidateProbe: "pass",
|
|
505
|
+
composition: "pass"
|
|
506
|
+
} },
|
|
507
|
+
attempts: [],
|
|
508
|
+
failureCount: {
|
|
509
|
+
target: 0,
|
|
510
|
+
previous: 0
|
|
511
|
+
},
|
|
512
|
+
recovery: {
|
|
513
|
+
policy: input.recoveryPolicy,
|
|
514
|
+
result: "pending"
|
|
515
|
+
},
|
|
516
|
+
events: [{
|
|
517
|
+
at: input.now,
|
|
518
|
+
kind: "prepared"
|
|
519
|
+
}]
|
|
520
|
+
};
|
|
521
|
+
atomicWriteJson(stateFile(stateDir, "launchCutover"), receipt);
|
|
522
|
+
atomicWriteJson(stateFile(stateDir, "launchSpec"), {
|
|
523
|
+
version: 1,
|
|
524
|
+
mode: "cutover",
|
|
525
|
+
cutoverId: input.id,
|
|
526
|
+
selected: "target",
|
|
527
|
+
previous: input.previous,
|
|
528
|
+
target: input.target,
|
|
529
|
+
...input.transition === void 0 ? {} : { transition: input.transition }
|
|
530
|
+
});
|
|
531
|
+
return receipt;
|
|
532
|
+
}
|
|
533
|
+
function updateRestartRecord(stateDir, receipt, outcome) {
|
|
534
|
+
const error = outcome === "restored" ? "launch cutover failed; the previous complete launch specification was restored" : outcome === "awaiting-user" ? "launch cutover failed; recovery policy requires waiting for user action" : outcome === "prepare-failed" ? "launch cutover was not started; the previous launch specification remains active" : void 0;
|
|
535
|
+
atomicWriteJson(stateFile(stateDir, "lastRestart"), {
|
|
536
|
+
exitAt: Date.now(),
|
|
537
|
+
...receipt.initiator !== void 0 ? { initiator: receipt.initiator } : {},
|
|
538
|
+
...receipt.child.previousPid !== void 0 ? { pid: receipt.child.previousPid } : {},
|
|
539
|
+
...error !== void 0 ? { error } : {},
|
|
540
|
+
cutover: {
|
|
541
|
+
id: receipt.id,
|
|
542
|
+
outcome,
|
|
543
|
+
receipt: stateFile(stateDir, "launchCutover")
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function requireReceipt(stateDir, id) {
|
|
548
|
+
const receipt = readCutoverReceipt(stateDir);
|
|
549
|
+
if (receipt === null || receipt.id !== id) throw new Error(`cutover receipt ${id} is not active`);
|
|
550
|
+
return receipt;
|
|
551
|
+
}
|
|
552
|
+
function appendEvent(receipt, kind, detail, now) {
|
|
553
|
+
receipt.updatedAt = now;
|
|
554
|
+
receipt.events = [...receipt.events, {
|
|
555
|
+
at: now,
|
|
556
|
+
kind,
|
|
557
|
+
...detail !== void 0 && detail !== "" ? { detail } : {}
|
|
558
|
+
}].slice(-100);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Apply one watchdog event. Arguments are deliberately credential-free; the
|
|
562
|
+
* launch URL itself never crosses this boundary or lands in the receipt.
|
|
563
|
+
*/
|
|
564
|
+
function recordCutoverEvent(stateDir, id, kind, args, now) {
|
|
565
|
+
const receipt = requireReceipt(stateDir, id);
|
|
566
|
+
const numberAt = (index, label) => {
|
|
567
|
+
const value = Number(args[index]);
|
|
568
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`${kind}: invalid ${label}`);
|
|
569
|
+
return value;
|
|
570
|
+
};
|
|
571
|
+
const pidAt = (index, label) => {
|
|
572
|
+
const value = numberAt(index, label);
|
|
573
|
+
if (value === 0) throw new Error(`${kind}: invalid ${label}`);
|
|
574
|
+
return value;
|
|
575
|
+
};
|
|
576
|
+
let terminal;
|
|
577
|
+
let stableSpec;
|
|
578
|
+
switch (kind) {
|
|
579
|
+
case "driver-started":
|
|
580
|
+
receipt.phase = "supervisor-starting";
|
|
581
|
+
receipt.supervisor.targetDriverPid = pidAt(0, "driver pid");
|
|
582
|
+
if (args[1] === void 0 || args[1] === "") throw new Error("driver-started: driver start identity is required");
|
|
583
|
+
receipt.supervisor.targetDriverStartToken = args[1];
|
|
584
|
+
break;
|
|
585
|
+
case "supervisor-ready":
|
|
586
|
+
receipt.phase = "supervisor-ready";
|
|
587
|
+
receipt.supervisor.targetPid = pidAt(0, "supervisor pid");
|
|
588
|
+
if (args[1] === void 0 || args[1] === "") throw new Error("supervisor-ready: supervisor start identity is required");
|
|
589
|
+
receipt.supervisor.targetStartToken = args[1];
|
|
590
|
+
break;
|
|
591
|
+
case "previous-supervisor-retired": {
|
|
592
|
+
const outcome = args[0];
|
|
593
|
+
if (outcome !== "yielded" && outcome !== "identity-gone" && outcome !== "forced") throw new Error("previous-supervisor-retired: invalid outcome");
|
|
594
|
+
receipt.supervisor.previousRetirement = outcome;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
case "child-started": {
|
|
598
|
+
const role = args[0];
|
|
599
|
+
if (role !== "target" && role !== "previous") throw new Error("child-started: invalid role");
|
|
600
|
+
const attempt = numberAt(1, "attempt");
|
|
601
|
+
const childPid = pidAt(2, "child pid");
|
|
602
|
+
const childStartToken = args[3];
|
|
603
|
+
if (childStartToken === void 0 || childStartToken === "") throw new Error("child-started: child start identity is required");
|
|
604
|
+
if (role === "target" && receipt.transition !== void 0 && receipt.transition.phase !== "applied") throw new Error("child-started: target transition has not been applied");
|
|
605
|
+
if (role === "previous" && receipt.transition !== void 0 && receipt.transition.phase !== "rolled-back") throw new Error("child-started: previous transition has not been rolled back");
|
|
606
|
+
receipt.phase = role === "target" ? "target-starting" : "restoring";
|
|
607
|
+
if (role === "target") receipt.child.targetPid = childPid;
|
|
608
|
+
else receipt.child.restoredPid = childPid;
|
|
609
|
+
receipt.authentication = { browserHandoff: receipt.authentication.browserHandoff === "off" ? "off" : "pending" };
|
|
610
|
+
receipt.browserHandoff = {
|
|
611
|
+
required: receipt.browserHandoff.required,
|
|
612
|
+
status: receipt.browserHandoff.required ? "pending" : "off"
|
|
613
|
+
};
|
|
614
|
+
rmSync(stateFile(stateDir, "browserHandoffAck"), { force: true });
|
|
615
|
+
delete receipt.readiness;
|
|
616
|
+
delete receipt.canary;
|
|
617
|
+
if (role === "target") receipt.targetValidation = {};
|
|
618
|
+
else receipt.recovery.validation = {};
|
|
619
|
+
receipt.attempts.push({
|
|
620
|
+
role,
|
|
621
|
+
number: attempt,
|
|
622
|
+
childPid,
|
|
623
|
+
childStartToken,
|
|
624
|
+
startedAt: now
|
|
625
|
+
});
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
case "ownership-stable": {
|
|
629
|
+
const role = args[0];
|
|
630
|
+
if (role !== "target" && role !== "previous") throw new Error("ownership-stable: invalid role");
|
|
631
|
+
const ownership = {
|
|
632
|
+
childPid: pidAt(1, "child pid"),
|
|
633
|
+
childStartToken: args[2] ?? "",
|
|
634
|
+
listenerPid: pidAt(3, "listener pid"),
|
|
635
|
+
listenerStartToken: args[4] ?? ""
|
|
636
|
+
};
|
|
637
|
+
if (ownership.childStartToken === "" || ownership.listenerStartToken === "") throw new Error("ownership-stable: child/listener start identity is required");
|
|
638
|
+
const stableWindowMs = numberAt(5, "stable window");
|
|
639
|
+
const retryCount = numberAt(6, "retry count");
|
|
640
|
+
if (stableWindowMs < 1 || retryCount !== 0) throw new Error("ownership-stable: stable window must be positive and retry count must be zero");
|
|
641
|
+
const attempt = [...receipt.attempts].reverse().find((candidate) => candidate.role === role && candidate.outcome === void 0);
|
|
642
|
+
if (attempt === void 0 || attempt.childPid !== ownership.childPid || attempt.childStartToken !== ownership.childStartToken) throw new Error(`ownership-stable: ${role} identity does not match the active attempt`);
|
|
643
|
+
if (role === "target") receipt.ownership.target = ownership;
|
|
644
|
+
else receipt.ownership.restored = ownership;
|
|
645
|
+
receipt.readiness = {
|
|
646
|
+
role,
|
|
647
|
+
childPid: ownership.childPid,
|
|
648
|
+
listenerPid: ownership.listenerPid,
|
|
649
|
+
stableWindowMs,
|
|
650
|
+
retryCount
|
|
651
|
+
};
|
|
652
|
+
if (role === "target") {
|
|
653
|
+
receipt.targetValidation ??= {};
|
|
654
|
+
receipt.targetValidation.readiness = receipt.readiness;
|
|
655
|
+
} else {
|
|
656
|
+
receipt.recovery.validation ??= {};
|
|
657
|
+
receipt.recovery.validation.readiness = receipt.readiness;
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
case "transport":
|
|
662
|
+
receipt.authentication.transportStatus = numberAt(0, "HTTP status");
|
|
663
|
+
break;
|
|
664
|
+
case "launch-url":
|
|
665
|
+
receipt.authentication.launchUrlObserved = true;
|
|
666
|
+
break;
|
|
667
|
+
case "auth-exchange":
|
|
668
|
+
receipt.authentication.exchangeStatus = numberAt(0, "HTTP status");
|
|
669
|
+
break;
|
|
670
|
+
case "authenticated":
|
|
671
|
+
receipt.authentication.authenticatedStatus = numberAt(0, "HTTP status");
|
|
672
|
+
break;
|
|
673
|
+
case "browser-handoff": {
|
|
674
|
+
const outcome = args[0];
|
|
675
|
+
if (outcome !== "acknowledged" && outcome !== "off" && outcome !== "failed") throw new Error("browser-handoff: invalid outcome");
|
|
676
|
+
if (outcome === "acknowledged") {
|
|
677
|
+
const channel = args[1];
|
|
678
|
+
const authentication = args[2];
|
|
679
|
+
const authority = args[3];
|
|
680
|
+
if (channel !== "original-tab" && channel !== "fallback-tab" || authentication !== "existing-cookie" && authentication !== "launch-url" || authority === void 0 || authority === "") throw new Error("browser-handoff: invalid acknowledgement evidence");
|
|
681
|
+
if (receipt.readiness === void 0) throw new Error("browser-handoff: server readiness is not proven");
|
|
682
|
+
if (receipt.readiness.role === "target" && receipt.targetValidation?.canary?.outcome !== "pass") throw new Error("browser-handoff: target canary has not passed");
|
|
683
|
+
if (receipt.readiness.role === "previous" && receipt.recovery.validation?.canary === void 0) throw new Error("browser-handoff: restored-previous canary has not settled");
|
|
684
|
+
receipt.browserHandoff = {
|
|
685
|
+
required: true,
|
|
686
|
+
status: "acknowledged",
|
|
687
|
+
channel,
|
|
688
|
+
authentication,
|
|
689
|
+
authority,
|
|
690
|
+
acknowledgedAt: now
|
|
691
|
+
};
|
|
692
|
+
} else receipt.browserHandoff = {
|
|
693
|
+
required: outcome !== "off",
|
|
694
|
+
status: outcome
|
|
695
|
+
};
|
|
696
|
+
receipt.authentication.browserHandoff = receipt.browserHandoff.status;
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
case "browser-fallback-opened":
|
|
700
|
+
if (!receipt.browserHandoff.required) throw new Error("browser-fallback-opened: handoff is disabled");
|
|
701
|
+
if (receipt.readiness?.role === "target" && receipt.targetValidation?.canary?.outcome !== "pass") throw new Error("browser-fallback-opened: target canary has not passed");
|
|
702
|
+
if (receipt.readiness?.role === "previous" && receipt.recovery.validation?.canary === void 0) throw new Error("browser-fallback-opened: restored-previous canary has not settled");
|
|
703
|
+
receipt.browserHandoff = {
|
|
704
|
+
required: true,
|
|
705
|
+
status: "fallback-opened"
|
|
706
|
+
};
|
|
707
|
+
receipt.authentication.browserHandoff = "fallback-opened";
|
|
708
|
+
break;
|
|
709
|
+
case "control-requested": {
|
|
710
|
+
const action = args[0];
|
|
711
|
+
if (action !== "abort" && action !== "restore-previous") throw new Error("control-requested: invalid action");
|
|
712
|
+
receipt.recovery.detail = action === "restore-previous" ? "operator explicitly requested restoration of the previous complete launch specification" : `operator aborted the cutover; applying pre-approved ${receipt.recovery.policy} policy`;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
case "attempt-failed": {
|
|
716
|
+
const role = args[0];
|
|
717
|
+
if (role !== "target" && role !== "previous") throw new Error("attempt-failed: invalid role");
|
|
718
|
+
const attempt = numberAt(1, "attempt");
|
|
719
|
+
const detail = args.slice(2).join(" ");
|
|
720
|
+
const found = [...receipt.attempts].reverse().find((candidate) => candidate.role === role && candidate.number === attempt);
|
|
721
|
+
if (found !== void 0) {
|
|
722
|
+
found.outcome = "failed";
|
|
723
|
+
if (detail !== "") found.detail = detail;
|
|
724
|
+
}
|
|
725
|
+
receipt.failureCount ??= {
|
|
726
|
+
target: 0,
|
|
727
|
+
previous: 0
|
|
728
|
+
};
|
|
729
|
+
receipt.failureCount[role] += 1;
|
|
730
|
+
receipt.phase = role === "target" ? "target-retrying" : "restoring";
|
|
731
|
+
receipt.recovery.detail = detail;
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
case "restoring":
|
|
735
|
+
if (receipt.transition !== void 0 && receipt.transition.phase !== "rolled-back") throw new Error("restoring: filesystem transition has not been rolled back");
|
|
736
|
+
receipt.phase = "restoring";
|
|
737
|
+
receipt.recovery.detail = args.join(" ");
|
|
738
|
+
receipt.authentication = { browserHandoff: receipt.authentication.browserHandoff === "off" ? "off" : "pending" };
|
|
739
|
+
receipt.browserHandoff = {
|
|
740
|
+
required: receipt.browserHandoff.required,
|
|
741
|
+
status: receipt.browserHandoff.required ? "pending" : "off"
|
|
742
|
+
};
|
|
743
|
+
rmSync(stateFile(stateDir, "browserHandoffAck"), { force: true });
|
|
744
|
+
delete receipt.readiness;
|
|
745
|
+
delete receipt.canary;
|
|
746
|
+
receipt.recovery.validation = {};
|
|
747
|
+
{
|
|
748
|
+
const state = readLaunchState(stateDir);
|
|
749
|
+
if (state?.mode !== "cutover" || state.cutoverId !== id) throw new Error("restoring: cutover launch state is missing");
|
|
750
|
+
atomicWriteJson(stateFile(stateDir, "launchSpec"), {
|
|
751
|
+
...state,
|
|
752
|
+
selected: "previous"
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
break;
|
|
756
|
+
case "transition": {
|
|
757
|
+
if (receipt.transition === void 0) throw new Error("transition: cutover has no transition plan");
|
|
758
|
+
const outcome = args[0];
|
|
759
|
+
if (args[1] !== receipt.transition.planSha256) throw new Error("transition: plan digest does not match");
|
|
760
|
+
if (outcome === "applied") {
|
|
761
|
+
if (receipt.transition.phase === "rolled-back") throw new Error("transition: a rolled-back plan cannot be applied");
|
|
762
|
+
receipt.transition = {
|
|
763
|
+
...receipt.transition,
|
|
764
|
+
phase: "applied"
|
|
765
|
+
};
|
|
766
|
+
} else if (outcome === "rolled-back") receipt.transition = {
|
|
767
|
+
...receipt.transition,
|
|
768
|
+
phase: "rolled-back"
|
|
769
|
+
};
|
|
770
|
+
else if (outcome === "apply-failed" || outcome === "rollback-failed") receipt.transition = {
|
|
771
|
+
...receipt.transition,
|
|
772
|
+
phase: "failed",
|
|
773
|
+
failureOperation: outcome === "apply-failed" ? "apply" : "rollback",
|
|
774
|
+
...args.length > 2 ? { detail: args.slice(2).join(" ") } : {}
|
|
775
|
+
};
|
|
776
|
+
else throw new Error("transition: invalid outcome");
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
case "canary": {
|
|
780
|
+
const explicitRole = args[0] === "target" || args[0] === "previous" ? args[0] : void 0;
|
|
781
|
+
const role = explicitRole ?? receipt.readiness?.role;
|
|
782
|
+
const outcomeIndex = explicitRole === void 0 ? 0 : 1;
|
|
783
|
+
const outcome = args[outcomeIndex];
|
|
784
|
+
if (role !== "target" && role !== "previous" || outcome !== "pass" && outcome !== "fail" && outcome !== "skipped") throw new Error("canary: invalid role or outcome");
|
|
785
|
+
if (role === "target" && outcome === "skipped") throw new Error("canary: target canary cannot be skipped");
|
|
786
|
+
const detail = args.slice(outcomeIndex + 1).join(" ");
|
|
787
|
+
const canary = {
|
|
788
|
+
outcome,
|
|
789
|
+
...detail === "" ? {} : { detail }
|
|
790
|
+
};
|
|
791
|
+
if (role === "target") {
|
|
792
|
+
receipt.targetValidation ??= {};
|
|
793
|
+
receipt.targetValidation.canary = canary;
|
|
794
|
+
receipt.canary = canary;
|
|
795
|
+
} else {
|
|
796
|
+
receipt.recovery.validation ??= {};
|
|
797
|
+
receipt.recovery.validation.canary = canary;
|
|
798
|
+
delete receipt.canary;
|
|
799
|
+
}
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
case "ready": {
|
|
803
|
+
const role = args[0];
|
|
804
|
+
if (role !== "target" && role !== "previous") throw new Error("ready: invalid role");
|
|
805
|
+
const state = readLaunchState(stateDir);
|
|
806
|
+
if (state?.mode !== "cutover" || state.cutoverId !== id) throw new Error("ready: cutover launch state is missing");
|
|
807
|
+
const ownership = role === "target" ? receipt.ownership?.target : receipt.ownership?.restored;
|
|
808
|
+
if (ownership === void 0 || receipt.readiness?.role !== role || receipt.readiness.childPid !== ownership.childPid || receipt.readiness.listenerPid !== ownership.listenerPid || receipt.readiness.retryCount !== 0) throw new Error(`ready: ${role} child/listener ownership was not stable and proven`);
|
|
809
|
+
const found = [...receipt.attempts].reverse().find((candidate) => candidate.role === role && candidate.outcome === void 0);
|
|
810
|
+
if (found === void 0 || found.childPid !== ownership.childPid || found.childStartToken !== ownership.childStartToken) throw new Error(`ready: ${role} ownership does not match the active attempt`);
|
|
811
|
+
if (role === "target" && receipt.targetValidation?.canary?.outcome !== "pass") throw new Error("ready: target canary has not passed");
|
|
812
|
+
if (role === "previous" && receipt.recovery.validation?.canary === void 0) throw new Error("ready: restored-previous canary has not settled");
|
|
813
|
+
if (role === "target" && receipt.transition !== void 0 && receipt.transition.phase !== "applied") throw new Error("ready: target transition is not applied");
|
|
814
|
+
if (role === "previous" && receipt.transition !== void 0 && receipt.transition.phase !== "rolled-back") throw new Error("ready: previous transition is not rolled back");
|
|
815
|
+
if (receipt.authentication.launchUrlObserved === true && receipt.browserHandoff.required && receipt.browserHandoff.status !== "acknowledged") throw new Error("ready: authenticated launch URL has no browser acknowledgement");
|
|
816
|
+
found.outcome = "ready";
|
|
817
|
+
if (role === "target") {
|
|
818
|
+
receipt.phase = "ready";
|
|
819
|
+
receipt.recovery.result = "not-needed";
|
|
820
|
+
terminal = "target-ready";
|
|
821
|
+
} else {
|
|
822
|
+
receipt.phase = "restored";
|
|
823
|
+
receipt.recovery.result = "restored";
|
|
824
|
+
terminal = "restored";
|
|
825
|
+
}
|
|
826
|
+
if (receipt.authentication.browserHandoff === "pending" && receipt.authentication.launchUrlObserved !== true) {
|
|
827
|
+
receipt.authentication.browserHandoff = "not-required";
|
|
828
|
+
receipt.browserHandoff = {
|
|
829
|
+
required: receipt.browserHandoff.required,
|
|
830
|
+
status: "not-required"
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
stableSpec = state[role];
|
|
834
|
+
atomicWriteJson(stateFile(stateDir, "instanceLaunch"), {
|
|
835
|
+
command: state[role].command,
|
|
836
|
+
source: "supervisor",
|
|
837
|
+
supervised: true,
|
|
838
|
+
port: state[role].port,
|
|
839
|
+
recordedAt: now
|
|
840
|
+
});
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
case "awaiting-user":
|
|
844
|
+
receipt.phase = "awaiting-user";
|
|
845
|
+
receipt.recovery.result = "waiting-for-user";
|
|
846
|
+
receipt.recovery.detail = args.join(" ");
|
|
847
|
+
terminal = "awaiting-user";
|
|
848
|
+
break;
|
|
849
|
+
case "prepare-failed": {
|
|
850
|
+
const state = readLaunchState(stateDir);
|
|
851
|
+
if (state?.mode === "cutover" && state.cutoverId === id) stableSpec = state.previous;
|
|
852
|
+
receipt.phase = "prepare-failed";
|
|
853
|
+
receipt.recovery.result = "prepare-failed";
|
|
854
|
+
receipt.recovery.detail = args.join(" ");
|
|
855
|
+
terminal = "prepare-failed";
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
default: throw new Error(`unknown cutover event ${kind}`);
|
|
859
|
+
}
|
|
860
|
+
appendEvent(receipt, kind, args.join(" "), now);
|
|
861
|
+
if (terminal !== void 0) updateRestartRecord(stateDir, receipt, terminal);
|
|
862
|
+
atomicWriteJson(stateFile(stateDir, "launchCutover"), receipt);
|
|
863
|
+
if (terminal !== void 0) clearCutoverControl(stateDir, id);
|
|
864
|
+
if (terminal !== void 0) {
|
|
865
|
+
if (terminal !== "target-ready" && terminal !== "restored") rmSync(stateFile(stateDir, "browserHandoffRequest"), { force: true });
|
|
866
|
+
rmSync(stateFile(stateDir, "browserHandoffAck"), { force: true });
|
|
867
|
+
}
|
|
868
|
+
if (stableSpec !== void 0) atomicWriteJson(stateFile(stateDir, "launchSpec"), {
|
|
869
|
+
version: 1,
|
|
870
|
+
mode: "stable",
|
|
871
|
+
active: stableSpec
|
|
872
|
+
});
|
|
873
|
+
return receipt;
|
|
874
|
+
}
|
|
875
|
+
/** A restarted instance must not wake/report while the watchdog is still proving the cutover. */
|
|
876
|
+
function cutoverBlocksWake(stateDir) {
|
|
877
|
+
const state = readLaunchState(stateDir);
|
|
878
|
+
if (state?.mode !== "cutover") return false;
|
|
879
|
+
const receipt = readCutoverReceipt(stateDir);
|
|
880
|
+
if (receipt === null || receipt.id !== state.cutoverId) return true;
|
|
881
|
+
return ![
|
|
882
|
+
"ready",
|
|
883
|
+
"restored",
|
|
884
|
+
"awaiting-user",
|
|
885
|
+
"prepare-failed"
|
|
886
|
+
].includes(receipt.phase);
|
|
887
|
+
}
|
|
888
|
+
/** The active transaction's full specs plus its redacted policy receipt. */
|
|
889
|
+
function activeCutover(stateDir) {
|
|
890
|
+
const state = readLaunchState(stateDir);
|
|
891
|
+
const receipt = readCutoverReceipt(stateDir);
|
|
892
|
+
if (state?.mode !== "cutover" || receipt === null || receipt.id !== state.cutoverId) return null;
|
|
893
|
+
if (receipt.phase === "ready" || receipt.phase === "restored" || receipt.phase === "prepare-failed") return null;
|
|
894
|
+
return {
|
|
895
|
+
state,
|
|
896
|
+
receipt
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region lib/types/restart-context.js
|
|
901
|
+
/**
|
|
902
|
+
* Restart-record context injection and interrupted-session continuity. After
|
|
903
|
+
* a scheduled restart, the FULL report waits for the initiating session's
|
|
904
|
+
* root agent, whenever it resumes (session restore is lazy, so no other
|
|
905
|
+
* session is ever woken for reporting); a record without an initiator is
|
|
906
|
+
* claimed by the first root agent created. Separately, a snapshot written at
|
|
907
|
+
* SIGTERM time (`interrupted-sessions.json`) records which root sessions had
|
|
908
|
+
* a live turn when the process stopped, so the next restart boot can resume
|
|
909
|
+
* those sessions and queue a "continue" turn. Pure logic reads the durable
|
|
910
|
+
* files; the plugin wires them into `agent/created` and `agent.followup`.
|
|
911
|
+
*/
|
|
912
|
+
/** Absolute path of the restart record inside a state directory. */
|
|
913
|
+
function restartRecordFile(stateDir) {
|
|
914
|
+
return stateFile(stateDir, "lastRestart");
|
|
915
|
+
}
|
|
916
|
+
/** Absolute path of the interrupted-session snapshot inside a state directory. */
|
|
917
|
+
function interruptedSnapshotFile(stateDir) {
|
|
918
|
+
return stateFile(stateDir, "interruptedSessions");
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* The pending restart record, or null when none is awaiting a report (absent,
|
|
922
|
+
* unparseable, or settled). The record stays pending until the initiator
|
|
923
|
+
* resumes or the next restart replaces it (a new exitAt) — those are the only
|
|
924
|
+
* retirement paths.
|
|
925
|
+
* @param stateDir - state directory.
|
|
926
|
+
* @returns the record without `reportedAt`, or null.
|
|
927
|
+
*/
|
|
928
|
+
function pendingRestartRecord(stateDir) {
|
|
929
|
+
const file = restartRecordFile(stateDir);
|
|
930
|
+
if (!existsSync(file)) return null;
|
|
931
|
+
try {
|
|
932
|
+
const record = JSON.parse(readFileSync(file, "utf8"));
|
|
933
|
+
return record.reportedAt !== void 0 ? null : record;
|
|
934
|
+
} catch {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* The model-visible restart report (Chinese product copy, factual).
|
|
940
|
+
* @param record - the pending restart record.
|
|
941
|
+
* @param canaryPending - whether the restart marker is still present (the
|
|
942
|
+
* watchdog has not yet run/cleared the canary).
|
|
943
|
+
* @returns the report text, or an empty string for a malformed record.
|
|
944
|
+
*/
|
|
945
|
+
function restartContextText(record, canaryPending) {
|
|
946
|
+
if (record.exitAt === void 0 && record.error === void 0) return "";
|
|
947
|
+
const time = record.exitAt !== void 0 ? new Date(record.exitAt).toISOString() : "未知时间";
|
|
948
|
+
if (record.cutover !== void 0) {
|
|
949
|
+
if (record.cutover.outcome === "target-ready") return `[ankh-guard] 服务于 ${time} 完成启动配置切换;新 supervisor 与 child 已接管,应用就绪证明(受保护宿主需要时包含认证交接)和金丝雀均通过。耐久回执:${record.cutover.receipt}。请读取回执中的 PID、配置摘要、认证、重试与恢复字段,并向用户生成最终报告。`;
|
|
950
|
+
if (record.cutover.outcome === "restored") return `[ankh-guard] 服务于 ${time} 尝试切换启动配置失败,但已按重启前批准的策略恢复上一份完整启动配置并重新就绪。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告失败、重试和恢复结果。`;
|
|
951
|
+
if (record.cutover.outcome === "awaiting-user") return `[ankh-guard] 服务于 ${time} 切换启动配置失败;批准的策略是停留等待用户,watchdog 未擅自重置仓库。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告当前等待点。`;
|
|
952
|
+
return `[ankh-guard] 启动配置切换在停止旧实例前失败,上一份启动配置仍有效。耐久回执:${record.cutover.receipt}。请读取回执并向用户报告准备阶段失败。`;
|
|
953
|
+
}
|
|
954
|
+
if (record.compositionRecovered === true) return `[ankh-guard] 服务于 ${time} 前后连续启动失败,watchdog 已自动回滚到上次健康的 profile 组合并恢复。${record.detail !== void 0 ? `回滚内容:${record.detail}。` : ""}原组合已备份到 state 的 composition-backup-* 目录。建议用户修复或卸载相关插件后重新安装验证。请向用户简要回报本次自动恢复与上述建议。`;
|
|
955
|
+
if (record.unexpected === true) return `[ankh-guard] 服务最近发生过一次非计划退出(崩溃或被手动停止):${time},watchdog 已自动拉起实例。请向用户简要回报这次非计划重启。`;
|
|
956
|
+
return `[ankh-guard] 服务最近重启过:${time},退出${record.error !== void 0 ? `失败(${record.error})` : "成功"},${canaryPending ? "金丝雀尚未完成" : "金丝雀已处理"}。请向用户简要回报本次重启结果。`;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* The model-visible "continue" prompt for a session whose turn was
|
|
960
|
+
* interrupted by the restart (its log tail was closed with
|
|
961
|
+
* `reason.kind === 'interrupted'` by crash-recovery repair).
|
|
962
|
+
* @param exitAt - epoch milliseconds of the exit that interrupted the turn.
|
|
963
|
+
* @returns the prompt text.
|
|
964
|
+
*/
|
|
965
|
+
function continueInterruptedText(exitAt) {
|
|
966
|
+
return `[ankh-guard] 服务于 ${new Date(exitAt).toISOString()} 重启,你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务;若任务已不再适用,简要说明原因后停止。`;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* The combined prompt for a session that is BOTH the restart's initiator and
|
|
970
|
+
* an interrupted session: one turn continues the work and reports the
|
|
971
|
+
* restart, instead of two near-duplicate turns.
|
|
972
|
+
* @param record - the pending restart record.
|
|
973
|
+
* @param canaryPending - whether the restart marker is still present.
|
|
974
|
+
* @returns the prompt text, or an empty string for a malformed record.
|
|
975
|
+
*/
|
|
976
|
+
function continueAndReportText(record, canaryPending) {
|
|
977
|
+
if (record.exitAt === void 0 && record.error === void 0) return "";
|
|
978
|
+
const time = record.exitAt !== void 0 ? new Date(record.exitAt).toISOString() : "未知时间";
|
|
979
|
+
if (record.cutover !== void 0) return `[ankh-guard] 服务于 ${time} 发生启动配置切换:${record.cutover.outcome === "target-ready" ? "新启动配置的应用就绪证明(受保护宿主需要时包含认证交接)与金丝雀均已通过" : record.cutover.outcome === "restored" ? "目标启动失败,上一份完整启动配置已恢复并重新就绪" : record.cutover.outcome === "awaiting-user" ? "目标启动失败,watchdog 正按批准策略等待用户" : "切换在停止旧实例前失败,上一份启动配置仍有效"}。你上次正在进行的回合被中断(日志已标记 interrupted)。耐久回执:${record.cutover.receipt}。请检查当前状态并继续未完成的任务,读取回执后向用户报告 supervisor/child PID、配置摘要、认证、重试与恢复结果。`;
|
|
980
|
+
if (record.compositionRecovered === true) return `[ankh-guard] 服务于 ${time} 前后连续启动失败,watchdog 已自动回滚到上次健康的 profile 组合并恢复(最近的插件变更已卸载)。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报本次自动恢复;若任务已不再适用,简要说明原因后停止。`;
|
|
981
|
+
if (record.unexpected === true) return `[ankh-guard] 服务于 ${time} 发生非计划退出(崩溃或被手动停止),watchdog 已自动拉起实例。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报这次非计划重启;若任务已不再适用,简要说明原因后停止。`;
|
|
982
|
+
return `[ankh-guard] 服务于 ${time} 重启,退出${record.error !== void 0 ? `失败(${record.error})` : "成功"},${canaryPending ? "金丝雀尚未完成" : "金丝雀已处理"}。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报本次重启结果;若任务已不再适用,简要说明原因后停止。`;
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Acknowledge a restart record so it injects exactly once.
|
|
986
|
+
* @param stateDir - state directory.
|
|
987
|
+
* @param record - the record being reported.
|
|
988
|
+
* @param now - epoch milliseconds of the acknowledgement.
|
|
989
|
+
*/
|
|
990
|
+
function acknowledgeRestartRecord(stateDir, record, now) {
|
|
991
|
+
try {
|
|
992
|
+
atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
|
|
993
|
+
...record,
|
|
994
|
+
reportedAt: now
|
|
995
|
+
})}\n`);
|
|
996
|
+
} catch {}
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Record an unplanned-exit recovery (the watchdog respawned the instance with
|
|
1000
|
+
* no restart marker), unless a record still awaits its report. Returns whether
|
|
1001
|
+
* the record was written — the caller (CLI verb, invoked by the watchdog) logs
|
|
1002
|
+
* either way, so the watchdog log must never claim a write that was skipped.
|
|
1003
|
+
* @param stateDir - state directory.
|
|
1004
|
+
* @param now - epoch milliseconds of the recovery.
|
|
1005
|
+
* @returns true when the record was written, false when a pending record was kept.
|
|
1006
|
+
*/
|
|
1007
|
+
function writeUnexpectedExitRecord(stateDir, now) {
|
|
1008
|
+
if (pendingRestartRecord(stateDir) !== null) return false;
|
|
1009
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1010
|
+
atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
|
|
1011
|
+
exitAt: now,
|
|
1012
|
+
unexpected: true
|
|
1013
|
+
})}\n`);
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Record the restart verb's outcome for the report machinery (the restart
|
|
1018
|
+
* verb is otherwise invisible to it: it writes no marker and no record, so a
|
|
1019
|
+
* restart it drove would never be reported to any session). Mirrors the exit
|
|
1020
|
+
* agent's record semantics; a still-pending earlier record is replaced only
|
|
1021
|
+
* by a completed newer restart.
|
|
1022
|
+
* @param stateDir - state directory.
|
|
1023
|
+
* @param record - the outcome fields (exitAt/pid for a stop, error on failure).
|
|
1024
|
+
*/
|
|
1025
|
+
function writeRestartOutcome(stateDir, record) {
|
|
1026
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1027
|
+
atomicWrite(restartRecordFile(stateDir), `${JSON.stringify(record)}\n`);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Record the watchdog's ADOPTION takeover — the first restart a deployment
|
|
1031
|
+
* ever sees: `supervise` handed the port to the watchdog, which stopped the
|
|
1032
|
+
* pre-existing owner and booted the supervised instance. The session that
|
|
1033
|
+
* established supervision promised the user a verification report; without
|
|
1034
|
+
* this record nothing wakes it after the bounce (the adoption writes no
|
|
1035
|
+
* restart marker and no outcome record). Never overwrites a record that still
|
|
1036
|
+
* awaits its report.
|
|
1037
|
+
* @param stateDir - state directory.
|
|
1038
|
+
* @param now - epoch milliseconds of the takeover boot.
|
|
1039
|
+
* @param initiator - the session that established supervision, when known.
|
|
1040
|
+
* @returns whether the record was written.
|
|
1041
|
+
*/
|
|
1042
|
+
function writeAdoptionRecord(stateDir, now, initiator) {
|
|
1043
|
+
if (pendingRestartRecord(stateDir) !== null) return false;
|
|
1044
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1045
|
+
atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
|
|
1046
|
+
exitAt: now,
|
|
1047
|
+
...initiator !== void 0 && initiator !== "" ? { initiator } : {}
|
|
1048
|
+
})}\n`);
|
|
1049
|
+
return true;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Record a composition rollback recovery: repeated boot failures whose
|
|
1053
|
+
* subject lived outside the checkout (a freshly installed plugin is the
|
|
1054
|
+
* common case) were recovered by restoring the last healthy profile
|
|
1055
|
+
* composition. The service is UP again minus the newest plugin change — the
|
|
1056
|
+
* recovery must be reported, never silent.
|
|
1057
|
+
*
|
|
1058
|
+
* Pending-record policy: a pending record with real diagnostics (error /
|
|
1059
|
+
* unexpected) is kept; a BARE exit outcome (exitAt/pid only) is merged over —
|
|
1060
|
+
* the watchdog knows the boot actually failed and recovered, so its record is
|
|
1061
|
+
* the truthful one, and it inherits the exit record's initiator so the report
|
|
1062
|
+
* still reaches its owner.
|
|
1063
|
+
* @param stateDir - state directory.
|
|
1064
|
+
* @param now - epoch milliseconds of the recovery boot.
|
|
1065
|
+
* @param detail - what changed in the rolled-back composition (e.g. the
|
|
1066
|
+
* unmounted bundle rows), for the report text.
|
|
1067
|
+
* @returns whether the record was written.
|
|
1068
|
+
*/
|
|
1069
|
+
function writeCompositionRecovery(stateDir, now, detail) {
|
|
1070
|
+
const pending = pendingRestartRecord(stateDir);
|
|
1071
|
+
if (pending !== null && (pending.error !== void 0 || pending.unexpected === true || pending.compositionRecovered === true)) return false;
|
|
1072
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1073
|
+
atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
|
|
1074
|
+
exitAt: now,
|
|
1075
|
+
compositionRecovered: true,
|
|
1076
|
+
...detail !== void 0 && detail !== "" ? { detail } : {},
|
|
1077
|
+
...pending?.initiator !== void 0 ? { initiator: pending.initiator } : {}
|
|
1078
|
+
})}\n`);
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Persist the launch record (atomic). The instance-facing side of
|
|
1083
|
+
* {@link writeInstanceLaunch}: only an `instance`-sourced record may be
|
|
1084
|
+
* replaced by another — a `supervisor` record carries the FULL supervision
|
|
1085
|
+
* chain (watchdog, launch wrapper) and the inner process must not overwrite
|
|
1086
|
+
* it with its own bare argv.
|
|
1087
|
+
* @param stateDir - state directory.
|
|
1088
|
+
* @param launch - the launch facts.
|
|
1089
|
+
* @returns whether the record was written.
|
|
1090
|
+
*/
|
|
1091
|
+
function writeInstanceLaunch(stateDir, launch) {
|
|
1092
|
+
if (readInstanceLaunch(stateDir)?.source === "supervisor") return false;
|
|
1093
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1094
|
+
atomicWrite(stateFile(stateDir, "instanceLaunch"), `${JSON.stringify(launch)}\n`);
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
/** POSIX single-quote one word for a shell command line. */
|
|
1098
|
+
function shellQuote(word) {
|
|
1099
|
+
return `'${word.replace(/'/g, "'\\''")}'`;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Render the instance's launch as a shell command: cwd, DSH_* env, and the
|
|
1103
|
+
* FULL node invocation — execArgv included, because a tsx chain
|
|
1104
|
+
* (`node --import tsx …`) rendered without it becomes a bare `node bin.ts`
|
|
1105
|
+
* that cannot load TypeScript sources.
|
|
1106
|
+
*/
|
|
1107
|
+
/**
|
|
1108
|
+
* Per-invocation transients, never launch configuration: the restart driver's
|
|
1109
|
+
* own marker, the calling session's identity, and the shell/web hand-off
|
|
1110
|
+
* vars. A recorded launch must not leak them into the next instance (a
|
|
1111
|
+
* restart driver reading DSH_ANKH_RESTART_DRIVER would misbehave; a stale
|
|
1112
|
+
* DSH_SESSION_ID misattributes reports).
|
|
1113
|
+
*/
|
|
1114
|
+
const TRANSIENT_ENV_KEYS = /* @__PURE__ */ new Set([
|
|
1115
|
+
"DSH_ANKH_RESTART_DRIVER",
|
|
1116
|
+
"DSH_SESSION_ID",
|
|
1117
|
+
"DSH_SESSION_JSONL",
|
|
1118
|
+
"DSH_WEB_URL",
|
|
1119
|
+
"DSH_SHELL"
|
|
1120
|
+
]);
|
|
1121
|
+
function buildLaunchCommand(execPath, execArgv, args, cwd, env) {
|
|
1122
|
+
const envPart = Object.entries(env).filter(([key]) => !TRANSIENT_ENV_KEYS.has(key)).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ");
|
|
1123
|
+
const argv = [
|
|
1124
|
+
execPath,
|
|
1125
|
+
...execArgv,
|
|
1126
|
+
...args
|
|
1127
|
+
].map(shellQuote).join(" ");
|
|
1128
|
+
return `cd ${shellQuote(cwd)} && ${envPart !== "" ? `${envPart} ` : ""}${argv}`;
|
|
1129
|
+
}
|
|
1130
|
+
/** Replace any record unconditionally (the supervisor's own write path). */
|
|
1131
|
+
function writeInstanceLaunchAsSupervisor(stateDir, launch) {
|
|
1132
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1133
|
+
atomicWrite(stateFile(stateDir, "instanceLaunch"), `${JSON.stringify(launch)}\n`);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Read the launch record, or null when absent/unparseable (older deployments,
|
|
1137
|
+
* or the plugin never applied in this home).
|
|
1138
|
+
* @param stateDir - state directory.
|
|
1139
|
+
* @returns the record, or null.
|
|
1140
|
+
*/
|
|
1141
|
+
function readInstanceLaunch(stateDir) {
|
|
1142
|
+
try {
|
|
1143
|
+
const launch = JSON.parse(readFileSync(stateFile(stateDir, "instanceLaunch"), "utf8"));
|
|
1144
|
+
return typeof launch.command === "string" ? launch : null;
|
|
1145
|
+
} catch {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Read the shutdown snapshot, or null when absent/unparseable. Malformed
|
|
1151
|
+
* snapshots are dropped by the caller's delete-after-read, never retried.
|
|
1152
|
+
* @param stateDir - state directory.
|
|
1153
|
+
* @returns the snapshot, or null.
|
|
1154
|
+
*/
|
|
1155
|
+
function readInterruptedSnapshot(stateDir) {
|
|
1156
|
+
const file = interruptedSnapshotFile(stateDir);
|
|
1157
|
+
if (!existsSync(file)) return null;
|
|
1158
|
+
try {
|
|
1159
|
+
const snapshot = JSON.parse(readFileSync(file, "utf8"));
|
|
1160
|
+
if (!Array.isArray(snapshot.resume) || !Array.isArray(snapshot.interrupted)) return null;
|
|
1161
|
+
return snapshot;
|
|
1162
|
+
} catch {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Write the shutdown snapshot (synchronous — called from a signal handler).
|
|
1168
|
+
* The state directory is created on demand: a fresh deployment has no
|
|
1169
|
+
* `$DSH_HOME/state` yet, and a missing directory must not silently drop the
|
|
1170
|
+
* snapshot.
|
|
1171
|
+
* @param stateDir - state directory.
|
|
1172
|
+
* @param snapshot - the snapshot to persist.
|
|
1173
|
+
*/
|
|
1174
|
+
function writeInterruptedSnapshot(stateDir, snapshot) {
|
|
1175
|
+
try {
|
|
1176
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1177
|
+
atomicWrite(interruptedSnapshotFile(stateDir), `${JSON.stringify(snapshot)}\n`);
|
|
1178
|
+
} catch {}
|
|
1179
|
+
}
|
|
1180
|
+
/** Write a small durable file atomically (tmp + rename in the same directory). */
|
|
1181
|
+
function atomicWrite(file, content) {
|
|
1182
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1183
|
+
writeFileSync(tmp, content);
|
|
1184
|
+
renameSync(tmp, file);
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Persist the skill-registration outcome (atomic, best-effort). A migration
|
|
1188
|
+
* or repackaging that drops the skill is otherwise invisible until someone
|
|
1189
|
+
* notices the catalog entry missing — this record lets `check-env` surface it.
|
|
1190
|
+
* @param stateDir - state directory.
|
|
1191
|
+
* @param record - the outcome of this boot's registration attempt.
|
|
1192
|
+
*/
|
|
1193
|
+
function writeSkillRegistration(stateDir, record) {
|
|
1194
|
+
try {
|
|
1195
|
+
mkdirSync(stateDir, { recursive: true });
|
|
1196
|
+
atomicWrite(stateFile(stateDir, "skillRegistration"), `${JSON.stringify(record)}\n`);
|
|
1197
|
+
} catch {}
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Read the skill-registration record, or null when absent/unparseable (the
|
|
1201
|
+
* plugin never applied with this state dir, or predates the record).
|
|
1202
|
+
* @param stateDir - state directory.
|
|
1203
|
+
* @returns the record, or null.
|
|
1204
|
+
*/
|
|
1205
|
+
function readSkillRegistration(stateDir) {
|
|
1206
|
+
try {
|
|
1207
|
+
const record = JSON.parse(readFileSync(stateFile(stateDir, "skillRegistration"), "utf8"));
|
|
1208
|
+
return typeof record.registered === "boolean" && typeof record.at === "number" ? record : null;
|
|
1209
|
+
} catch {
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/** The tool whose open call means the turn is parked waiting for the human. */
|
|
1214
|
+
const USER_INPUT_TOOL = "ask_user_question";
|
|
1215
|
+
/**
|
|
1216
|
+
* Whether the session's last turn ended PARKED on user input: interrupted
|
|
1217
|
+
* while waiting on an open `ask_user_question` call, or on an approval
|
|
1218
|
+
* (`approval/asked` with no `approval/decided` in the same turn / after the
|
|
1219
|
+
* turn's start). A parked turn has no interrupted WORK — the card persists in
|
|
1220
|
+
* the log and the user answers whenever — so the restart resume must leave
|
|
1221
|
+
* such sessions alone (no resume, no continue injection, no replayed card).
|
|
1222
|
+
* @param events - the session's durable events (crash-repair already applied).
|
|
1223
|
+
* @returns whether the last interrupted turn was parked on user input.
|
|
1224
|
+
*/
|
|
1225
|
+
function isParkedOnUserInput(events) {
|
|
1226
|
+
let parkedTurn;
|
|
1227
|
+
let parkedTurnStartSeq = -1;
|
|
1228
|
+
for (const event of events) {
|
|
1229
|
+
const reason = event.data["reason"];
|
|
1230
|
+
if (event.type === "turn/end" && reason?.kind === "interrupted") parkedTurn = event.data["turn"];
|
|
1231
|
+
}
|
|
1232
|
+
if (parkedTurn === void 0) return false;
|
|
1233
|
+
for (const event of events) if (event.type === "turn/start" && event.data["turn"] === parkedTurn) parkedTurnStartSeq = event.seq ?? -1;
|
|
1234
|
+
let lastToolName;
|
|
1235
|
+
let pendingApproval = false;
|
|
1236
|
+
for (const event of events) {
|
|
1237
|
+
if (!(event.data["turn"] === parkedTurn || parkedTurnStartSeq >= 0 && (event.seq ?? -1) >= parkedTurnStartSeq)) continue;
|
|
1238
|
+
if (event.type === "tool/call") lastToolName = event.data["name"];
|
|
1239
|
+
if (event.type === "approval/asked") pendingApproval = true;
|
|
1240
|
+
if (event.type === "approval/decided") pendingApproval = false;
|
|
1241
|
+
}
|
|
1242
|
+
return lastToolName === USER_INPUT_TOOL || pendingApproval;
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
export { writeStableLaunchSpec as A, prepareLaunchCutover as C, selectedLaunchSpec as D, recordCutoverEvent as E, workingTreeChanges as F, currentHead as M, isWorkingTreeClean as N, summarizeLaunchState as O, resetToCheckpoint as P, cutoverBlocksWake as S, readLaunchState as T, writeRestartOutcome as _, interruptedSnapshotFile as a, activeCutover as b, readInstanceLaunch as c, restartContextText as d, writeAdoptionRecord as f, writeInterruptedSnapshot as g, writeInstanceLaunchAsSupervisor as h, continueInterruptedText as i, commitCheckpoint as j, writeCutoverControl as k, readInterruptedSnapshot as l, writeInstanceLaunch as m, buildLaunchCommand as n, isParkedOnUserInput as o, writeCompositionRecovery as p, continueAndReportText as r, pendingRestartRecord as s, acknowledgeRestartRecord as t, readSkillRegistration as u, writeSkillRegistration as v, readCutoverReceipt as w, commandSha256 as x, writeUnexpectedExitRecord as y };
|