@khorsheed/dsh-ankh-guard 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1245 +0,0 @@
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 };