@khorsheed/dsh-ankh-guard 0.1.0 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -0,0 +1,823 @@
1
+ /**
2
+ * Durable launch-configuration cutovers.
3
+ *
4
+ * A launch change is not a repository rollback: the command, home, credential
5
+ * repository, host root, profile, port, readiness handoff, and recovery choice
6
+ * move as one unit. The selected side lives in launch-spec.json (one atomic
7
+ * write); the redacted,
8
+ * append-only-ish operational receipt lives in launch-cutover.json. The full
9
+ * commands never enter the receipt because a launch command may carry secret
10
+ * environment values.
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ import { stateFile } from "./state-files.js";
16
+ function atomicWriteJson(file, value) {
17
+ mkdirSync(dirname(file), { recursive: true });
18
+ const tmp = `${file}.${process.pid}.tmp`;
19
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
20
+ renameSync(tmp, file);
21
+ // rename over an older file keeps the tmp inode's mode on POSIX. chmod is a
22
+ // belt-and-suspenders guarantee for state dirs copied from older installs.
23
+ try {
24
+ chmodSync(file, 0o600);
25
+ }
26
+ catch { /* best effort on non-POSIX filesystems */ }
27
+ }
28
+ function isLaunchSpec(value) {
29
+ if (typeof value !== 'object' || value === null)
30
+ return false;
31
+ const spec = value;
32
+ return spec.version === 1
33
+ && typeof spec.command === 'string' && spec.command !== ''
34
+ && Number.isInteger(spec.port) && (spec.port ?? 0) > 0 && (spec.port ?? 0) <= 65535
35
+ && typeof spec.home === 'string' && spec.home !== ''
36
+ && typeof spec.credentialRepo === 'string' && spec.credentialRepo !== ''
37
+ && typeof spec.harnessRoot === 'string' && spec.harnessRoot !== ''
38
+ && typeof spec.profile === 'string' && spec.profile !== ''
39
+ && (spec.preflight === undefined || isLaunchPreflightSpec(spec.preflight));
40
+ }
41
+ function isLaunchPreflightSpec(value) {
42
+ if (typeof value !== 'object' || value === null)
43
+ return false;
44
+ const spec = value;
45
+ return spec.version === 1
46
+ && (spec.surface === 'source' || spec.surface === 'built')
47
+ && typeof spec.runnerExecutable === 'string' && spec.runnerExecutable !== ''
48
+ && Array.isArray(spec.runnerRuntimeArgs) && spec.runnerRuntimeArgs.every(arg => typeof arg === 'string')
49
+ && typeof spec.runnerPath === 'string' && spec.runnerPath !== ''
50
+ && typeof spec.runnerSha256 === 'string' && /^[a-f0-9]{64}$/.test(spec.runnerSha256)
51
+ && typeof spec.installAnchor === 'string' && spec.installAnchor !== ''
52
+ && typeof spec.installAnchorSha256 === 'string' && /^[a-f0-9]{64}$/.test(spec.installAnchorSha256)
53
+ && typeof spec.hostPackageVersion === 'string' && spec.hostPackageVersion !== ''
54
+ && typeof spec.targetCommandSha256 === 'string' && /^[a-f0-9]{64}$/.test(spec.targetCommandSha256)
55
+ && (spec.candidateProbeCommand === undefined
56
+ ? spec.candidateProbeSha256 === undefined && spec.candidateProbeProvenance === undefined
57
+ : typeof spec.candidateProbeCommand === 'string' && spec.candidateProbeCommand !== ''
58
+ && typeof spec.candidateProbeSha256 === 'string' && /^[a-f0-9]{64}$/.test(spec.candidateProbeSha256)
59
+ && (spec.candidateProbeProvenance === undefined || spec.candidateProbeProvenance === 'caller-supplied'));
60
+ }
61
+ export function commandSha256(command) {
62
+ return createHash('sha256').update(command).digest('hex');
63
+ }
64
+ function isTransitionReference(value) {
65
+ if (typeof value !== 'object' || value === null)
66
+ return false;
67
+ const reference = value;
68
+ return reference.version === 1
69
+ && typeof reference.planPath === 'string' && reference.planPath !== ''
70
+ && typeof reference.planSha256 === 'string' && /^[a-f0-9]{64}$/.test(reference.planSha256)
71
+ && Number.isInteger(reference.operationCount) && (reference.operationCount ?? 0) > 0;
72
+ }
73
+ /** Read the selected launch state, or null for a deployment predating this protocol. */
74
+ export function readLaunchState(stateDir) {
75
+ try {
76
+ const parsed = JSON.parse(readFileSync(stateFile(stateDir, 'launchSpec'), 'utf8'));
77
+ if (parsed.version !== 1)
78
+ return null;
79
+ if (parsed.mode === 'stable' && isLaunchSpec(parsed.active))
80
+ return parsed;
81
+ if (parsed.mode === 'cutover' && typeof parsed.cutoverId === 'string'
82
+ && (parsed.selected === 'target' || parsed.selected === 'previous')
83
+ && isLaunchSpec(parsed.previous) && isLaunchSpec(parsed.target)
84
+ && (parsed.transition === undefined || isTransitionReference(parsed.transition))) {
85
+ return parsed;
86
+ }
87
+ return null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ /** The exact spec a newly starting supervisor must honor. */
94
+ export function selectedLaunchSpec(state) {
95
+ if (state.mode === 'stable')
96
+ return state.active;
97
+ return state[state.selected];
98
+ }
99
+ /** Persist an ordinary, non-transactional active spec. */
100
+ export function writeStableLaunchSpec(stateDir, spec, ifAbsent = false) {
101
+ if (!isLaunchSpec(spec))
102
+ throw new Error('invalid launch specification');
103
+ if (ifAbsent && readLaunchState(stateDir) !== null)
104
+ return false;
105
+ atomicWriteJson(stateFile(stateDir, 'launchSpec'), { version: 1, mode: 'stable', active: spec });
106
+ return true;
107
+ }
108
+ export function summarizeLaunchSpec(spec) {
109
+ return {
110
+ commandSha256: commandSha256(spec.command).slice(0, 16),
111
+ port: spec.port,
112
+ home: spec.home,
113
+ credentialRepo: spec.credentialRepo,
114
+ harnessRoot: spec.harnessRoot,
115
+ profile: spec.profile,
116
+ ...(spec.preflight === undefined ? {} : {
117
+ preflight: {
118
+ surface: spec.preflight.surface,
119
+ runnerExecutable: spec.preflight.runnerExecutable,
120
+ runnerRuntimeArgs: spec.preflight.runnerRuntimeArgs,
121
+ runnerPath: spec.preflight.runnerPath,
122
+ runnerSha256: spec.preflight.runnerSha256,
123
+ installAnchor: spec.preflight.installAnchor,
124
+ installAnchorSha256: spec.preflight.installAnchorSha256,
125
+ hostPackageVersion: spec.preflight.hostPackageVersion,
126
+ targetCommandSha256: spec.preflight.targetCommandSha256,
127
+ ...(spec.preflight.candidateProbeSha256 === undefined
128
+ ? {} : {
129
+ candidateProbeSha256: spec.preflight.candidateProbeSha256,
130
+ candidateProbeProvenance: 'caller-supplied',
131
+ }),
132
+ },
133
+ }),
134
+ };
135
+ }
136
+ /** Safe operator view: preserve topology and selection without printing commands. */
137
+ export function summarizeLaunchState(state) {
138
+ if (state === null)
139
+ return null;
140
+ if (state.mode === 'stable') {
141
+ return { version: state.version, mode: state.mode, active: summarizeLaunchSpec(state.active) };
142
+ }
143
+ return {
144
+ version: state.version,
145
+ mode: state.mode,
146
+ cutoverId: state.cutoverId,
147
+ selected: state.selected,
148
+ previous: summarizeLaunchSpec(state.previous),
149
+ target: summarizeLaunchSpec(state.target),
150
+ ...(state.transition === undefined ? {} : {
151
+ transition: {
152
+ planSha256: state.transition.planSha256,
153
+ operationCount: state.transition.operationCount,
154
+ },
155
+ }),
156
+ };
157
+ }
158
+ /** Read the durable receipt, or null when absent/malformed. */
159
+ export function readCutoverReceipt(stateDir) {
160
+ try {
161
+ const receipt = JSON.parse(readFileSync(stateFile(stateDir, 'launchCutover'), 'utf8'));
162
+ if (receipt.version !== 1 || typeof receipt.id !== 'string' || !Array.isArray(receipt.events))
163
+ return null;
164
+ // Rolling migration from receipts written before browser acknowledgement
165
+ // became its own evidence plane. A legacy "accepted" means only that the
166
+ // opener returned success; never relabel it as a page acknowledgement.
167
+ if (receipt.browserHandoff === undefined) {
168
+ const legacy = receipt.authentication?.browserHandoff;
169
+ const status = legacy === 'off' ? 'off'
170
+ : legacy === 'not-required' ? 'not-required'
171
+ : legacy === 'failed' ? 'failed'
172
+ : legacy === 'accepted' ? 'fallback-opened' : 'pending';
173
+ receipt.browserHandoff = {
174
+ required: status !== 'off',
175
+ status,
176
+ };
177
+ if (legacy === 'accepted')
178
+ receipt.authentication.browserHandoff = status;
179
+ }
180
+ // Rolling migration for an in-flight receipt from the single canary
181
+ // field era. Attribute it only when the current readiness role makes the
182
+ // provenance unambiguous; new recovery receipts never reuse that field.
183
+ if (receipt.readiness?.role === 'target') {
184
+ receipt.targetValidation ??= {};
185
+ receipt.targetValidation.readiness ??= receipt.readiness;
186
+ if (receipt.targetValidation.canary === undefined && receipt.canary !== undefined) {
187
+ receipt.targetValidation.canary = receipt.canary;
188
+ }
189
+ }
190
+ else if (receipt.readiness?.role === 'previous') {
191
+ receipt.recovery.validation ??= {};
192
+ receipt.recovery.validation.readiness ??= receipt.readiness;
193
+ if (receipt.recovery.validation.canary === undefined && receipt.canary !== undefined) {
194
+ receipt.recovery.validation.canary = receipt.canary;
195
+ delete receipt.canary;
196
+ }
197
+ }
198
+ return receipt;
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
204
+ /** Persist an operator control request for the current watchdog to consume. */
205
+ export function writeCutoverControl(stateDir, cutoverId, action, now) {
206
+ const active = activeCutover(stateDir);
207
+ if (active === null || active.receipt.id !== cutoverId)
208
+ throw new Error(`cutover ${cutoverId} is not active`);
209
+ const request = { version: 1, cutoverId, action, requestedAt: now };
210
+ // Separate monotonic markers remove the read-then-overwrite race between
211
+ // independent operator sessions. Once restore exists, no later abort write
212
+ // can downgrade the durable effective action.
213
+ if (action === 'restore-previous') {
214
+ atomicWriteJson(stateFile(stateDir, 'cutoverRestorePrevious'), request);
215
+ }
216
+ else {
217
+ const existing = readControlFile(stateFile(stateDir, 'cutoverRestorePrevious'));
218
+ if (existing?.cutoverId === cutoverId)
219
+ return existing;
220
+ atomicWriteJson(stateFile(stateDir, 'cutoverAbort'), request);
221
+ }
222
+ const effective = readCutoverControl(stateDir);
223
+ return effective?.cutoverId === cutoverId ? effective : request;
224
+ }
225
+ function readControlFile(file) {
226
+ try {
227
+ const value = JSON.parse(readFileSync(file, 'utf8'));
228
+ if (value.version !== 1 || typeof value.cutoverId !== 'string'
229
+ || (value.action !== 'abort' && value.action !== 'restore-previous')
230
+ || typeof value.requestedAt !== 'number')
231
+ return null;
232
+ return value;
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ export function readCutoverControl(stateDir) {
239
+ const restore = readControlFile(stateFile(stateDir, 'cutoverRestorePrevious'));
240
+ if (restore?.action === 'restore-previous')
241
+ return restore;
242
+ const legacy = readControlFile(stateFile(stateDir, 'cutoverControl'));
243
+ if (legacy?.action === 'restore-previous')
244
+ return legacy;
245
+ return readControlFile(stateFile(stateDir, 'cutoverAbort')) ?? legacy;
246
+ }
247
+ export function clearCutoverControl(stateDir, cutoverId) {
248
+ for (const role of ['cutoverRestorePrevious', 'cutoverAbort', 'cutoverControl']) {
249
+ const file = stateFile(stateDir, role);
250
+ if (readControlFile(file)?.cutoverId === cutoverId)
251
+ rmSync(file, { force: true });
252
+ }
253
+ }
254
+ /**
255
+ * Prepare one cutover. The receipt lands first; the single launch-state rename
256
+ * is the commit point selecting target. A crash before that rename leaves the
257
+ * previous stable state authoritative and the old supervisor untouched.
258
+ */
259
+ export function prepareLaunchCutover(stateDir, input) {
260
+ if (!isLaunchSpec(input.previous) || !isLaunchSpec(input.target))
261
+ throw new Error('invalid launch specification');
262
+ if (input.id === '')
263
+ throw new Error('cutover id is required');
264
+ if (!Number.isInteger(input.previousSupervisorPid) || input.previousSupervisorPid <= 0)
265
+ throw new Error('invalid previous supervisor pid');
266
+ if (input.previousSupervisorStartToken === '')
267
+ throw new Error('previous supervisor start identity is required');
268
+ for (const [label, value] of Object.entries({
269
+ previousChildPid: input.previousOwnership.childPid,
270
+ previousListenerPid: input.previousOwnership.listenerPid,
271
+ })) {
272
+ if (!Number.isInteger(value) || value <= 0)
273
+ throw new Error(`invalid ${label}`);
274
+ }
275
+ if (input.previousOwnership.childStartToken === '' || input.previousOwnership.listenerStartToken === '') {
276
+ throw new Error('previous child/listener start identity is required');
277
+ }
278
+ if (input.previous.port !== input.target.port) {
279
+ throw new Error('online cutover requires previous and target to use the same port');
280
+ }
281
+ if (input.transition !== undefined) {
282
+ if (!isTransitionReference(input.transition))
283
+ throw new Error('invalid transition reference');
284
+ if (input.previous.home !== input.target.home) {
285
+ throw new Error('filesystem transition requires previous and target to share one home');
286
+ }
287
+ }
288
+ // A terminal transaction normally clears these. Remove any abandoned
289
+ // marker before creating a new ID so precedence is scoped to this cutover.
290
+ for (const role of ['cutoverRestorePrevious', 'cutoverAbort', 'cutoverControl']) {
291
+ rmSync(stateFile(stateDir, role), { force: true });
292
+ }
293
+ rmSync(stateFile(stateDir, 'browserHandoffRequest'), { force: true });
294
+ rmSync(stateFile(stateDir, 'browserHandoffAck'), { force: true });
295
+ const receipt = {
296
+ version: 1,
297
+ id: input.id,
298
+ phase: 'prepared',
299
+ preparedAt: input.now,
300
+ updatedAt: input.now,
301
+ ...(input.initiator !== undefined && input.initiator !== '' ? { initiator: input.initiator } : {}),
302
+ previous: summarizeLaunchSpec(input.previous),
303
+ target: summarizeLaunchSpec(input.target),
304
+ ...(input.transition === undefined ? {} : {
305
+ transition: {
306
+ planSha256: input.transition.planSha256,
307
+ operationCount: input.transition.operationCount,
308
+ phase: 'pending',
309
+ },
310
+ }),
311
+ supervisor: {
312
+ previousPid: input.previousSupervisorPid,
313
+ previousStartToken: input.previousSupervisorStartToken,
314
+ },
315
+ child: { previousPid: input.previousOwnership.childPid },
316
+ ownership: { previous: input.previousOwnership },
317
+ authentication: {
318
+ browserHandoff: input.browserHandoff === 'required' ? 'pending' : 'off',
319
+ },
320
+ browserHandoff: {
321
+ required: input.browserHandoff === 'required',
322
+ status: input.browserHandoff === 'required' ? 'pending' : 'off',
323
+ },
324
+ ...(input.target.preflight?.candidateProbeSha256 === undefined ? {} : {
325
+ preflight: {
326
+ surface: input.target.preflight.surface,
327
+ runnerExecutable: input.target.preflight.runnerExecutable,
328
+ runnerRuntimeArgs: input.target.preflight.runnerRuntimeArgs,
329
+ runnerPath: input.target.preflight.runnerPath,
330
+ runnerSha256: input.target.preflight.runnerSha256,
331
+ installAnchor: input.target.preflight.installAnchor,
332
+ installAnchorSha256: input.target.preflight.installAnchorSha256,
333
+ hostPackageVersion: input.target.preflight.hostPackageVersion,
334
+ targetCommandSha256: input.target.preflight.targetCommandSha256,
335
+ candidateProbeSha256: input.target.preflight.candidateProbeSha256,
336
+ candidateProbeProvenance: 'caller-supplied',
337
+ candidateProbe: 'pass',
338
+ composition: 'pass',
339
+ },
340
+ }),
341
+ attempts: [],
342
+ failureCount: { target: 0, previous: 0 },
343
+ recovery: { policy: input.recoveryPolicy, result: 'pending' },
344
+ events: [{ at: input.now, kind: 'prepared' }],
345
+ };
346
+ atomicWriteJson(stateFile(stateDir, 'launchCutover'), receipt);
347
+ atomicWriteJson(stateFile(stateDir, 'launchSpec'), {
348
+ version: 1,
349
+ mode: 'cutover',
350
+ cutoverId: input.id,
351
+ selected: 'target',
352
+ previous: input.previous,
353
+ target: input.target,
354
+ ...(input.transition === undefined ? {} : { transition: input.transition }),
355
+ });
356
+ return receipt;
357
+ }
358
+ function updateRestartRecord(stateDir, receipt, outcome) {
359
+ const error = outcome === 'restored'
360
+ ? 'launch cutover failed; the previous complete launch specification was restored'
361
+ : outcome === 'awaiting-user'
362
+ ? 'launch cutover failed; recovery policy requires waiting for user action'
363
+ : outcome === 'prepare-failed'
364
+ ? 'launch cutover was not started; the previous launch specification remains active'
365
+ : undefined;
366
+ atomicWriteJson(stateFile(stateDir, 'lastRestart'), {
367
+ exitAt: Date.now(),
368
+ ...(receipt.initiator !== undefined ? { initiator: receipt.initiator } : {}),
369
+ ...(receipt.child.previousPid !== undefined ? { pid: receipt.child.previousPid } : {}),
370
+ ...(error !== undefined ? { error } : {}),
371
+ cutover: {
372
+ id: receipt.id,
373
+ outcome,
374
+ receipt: stateFile(stateDir, 'launchCutover'),
375
+ },
376
+ });
377
+ }
378
+ function requireReceipt(stateDir, id) {
379
+ const receipt = readCutoverReceipt(stateDir);
380
+ if (receipt === null || receipt.id !== id)
381
+ throw new Error(`cutover receipt ${id} is not active`);
382
+ return receipt;
383
+ }
384
+ function appendEvent(receipt, kind, detail, now) {
385
+ receipt.updatedAt = now;
386
+ receipt.events = [...receipt.events, { at: now, kind, ...(detail !== undefined && detail !== '' ? { detail } : {}) }].slice(-100);
387
+ }
388
+ /**
389
+ * Apply one watchdog event. Arguments are deliberately credential-free; the
390
+ * launch URL itself never crosses this boundary or lands in the receipt.
391
+ */
392
+ export function recordCutoverEvent(stateDir, id, kind, args, now) {
393
+ const receipt = requireReceipt(stateDir, id);
394
+ const numberAt = (index, label) => {
395
+ const value = Number(args[index]);
396
+ if (!Number.isInteger(value) || value < 0)
397
+ throw new Error(`${kind}: invalid ${label}`);
398
+ return value;
399
+ };
400
+ const pidAt = (index, label) => {
401
+ const value = numberAt(index, label);
402
+ if (value === 0)
403
+ throw new Error(`${kind}: invalid ${label}`);
404
+ return value;
405
+ };
406
+ let terminal;
407
+ let stableSpec;
408
+ switch (kind) {
409
+ case 'driver-started':
410
+ receipt.phase = 'supervisor-starting';
411
+ receipt.supervisor.targetDriverPid = pidAt(0, 'driver pid');
412
+ if (args[1] === undefined || args[1] === '') {
413
+ throw new Error('driver-started: driver start identity is required');
414
+ }
415
+ receipt.supervisor.targetDriverStartToken = args[1];
416
+ break;
417
+ case 'supervisor-ready':
418
+ receipt.phase = 'supervisor-ready';
419
+ receipt.supervisor.targetPid = pidAt(0, 'supervisor pid');
420
+ if (args[1] === undefined || args[1] === '') {
421
+ throw new Error('supervisor-ready: supervisor start identity is required');
422
+ }
423
+ receipt.supervisor.targetStartToken = args[1];
424
+ break;
425
+ case 'previous-supervisor-retired': {
426
+ const outcome = args[0];
427
+ if (outcome !== 'yielded' && outcome !== 'identity-gone' && outcome !== 'forced') {
428
+ throw new Error('previous-supervisor-retired: invalid outcome');
429
+ }
430
+ receipt.supervisor.previousRetirement = outcome;
431
+ break;
432
+ }
433
+ case 'child-started': {
434
+ const role = args[0];
435
+ if (role !== 'target' && role !== 'previous')
436
+ throw new Error('child-started: invalid role');
437
+ const attempt = numberAt(1, 'attempt');
438
+ const childPid = pidAt(2, 'child pid');
439
+ const childStartToken = args[3];
440
+ if (childStartToken === undefined || childStartToken === '')
441
+ throw new Error('child-started: child start identity is required');
442
+ if (role === 'target' && receipt.transition !== undefined && receipt.transition.phase !== 'applied') {
443
+ throw new Error('child-started: target transition has not been applied');
444
+ }
445
+ if (role === 'previous' && receipt.transition !== undefined && receipt.transition.phase !== 'rolled-back') {
446
+ throw new Error('child-started: previous transition has not been rolled back');
447
+ }
448
+ receipt.phase = role === 'target' ? 'target-starting' : 'restoring';
449
+ if (role === 'target')
450
+ receipt.child.targetPid = childPid;
451
+ else
452
+ receipt.child.restoredPid = childPid;
453
+ // Authentication, handoff, readiness, and canary belong to this exact
454
+ // process identity. A retry must never inherit a previous process's
455
+ // accepted launch URL or terminal proof.
456
+ receipt.authentication = {
457
+ browserHandoff: receipt.authentication.browserHandoff === 'off' ? 'off' : 'pending',
458
+ };
459
+ receipt.browserHandoff = {
460
+ required: receipt.browserHandoff.required,
461
+ status: receipt.browserHandoff.required ? 'pending' : 'off',
462
+ };
463
+ rmSync(stateFile(stateDir, 'browserHandoffAck'), { force: true });
464
+ delete receipt.readiness;
465
+ delete receipt.canary;
466
+ if (role === 'target')
467
+ receipt.targetValidation = {};
468
+ else
469
+ receipt.recovery.validation = {};
470
+ receipt.attempts.push({ role, number: attempt, childPid, childStartToken, startedAt: now });
471
+ break;
472
+ }
473
+ case 'ownership-stable': {
474
+ const role = args[0];
475
+ if (role !== 'target' && role !== 'previous')
476
+ throw new Error('ownership-stable: invalid role');
477
+ const ownership = {
478
+ childPid: pidAt(1, 'child pid'),
479
+ childStartToken: args[2] ?? '',
480
+ listenerPid: pidAt(3, 'listener pid'),
481
+ listenerStartToken: args[4] ?? '',
482
+ };
483
+ if (ownership.childStartToken === '' || ownership.listenerStartToken === '') {
484
+ throw new Error('ownership-stable: child/listener start identity is required');
485
+ }
486
+ const stableWindowMs = numberAt(5, 'stable window');
487
+ const retryCount = numberAt(6, 'retry count');
488
+ if (stableWindowMs < 1 || retryCount !== 0)
489
+ throw new Error('ownership-stable: stable window must be positive and retry count must be zero');
490
+ const attempt = [...receipt.attempts].reverse().find(candidate => candidate.role === role && candidate.outcome === undefined);
491
+ if (attempt === undefined || attempt.childPid !== ownership.childPid
492
+ || attempt.childStartToken !== ownership.childStartToken) {
493
+ throw new Error(`ownership-stable: ${role} identity does not match the active attempt`);
494
+ }
495
+ if (role === 'target')
496
+ receipt.ownership.target = ownership;
497
+ else
498
+ receipt.ownership.restored = ownership;
499
+ receipt.readiness = {
500
+ role, childPid: ownership.childPid, listenerPid: ownership.listenerPid,
501
+ stableWindowMs, retryCount,
502
+ };
503
+ if (role === 'target') {
504
+ receipt.targetValidation ??= {};
505
+ receipt.targetValidation.readiness = receipt.readiness;
506
+ }
507
+ else {
508
+ receipt.recovery.validation ??= {};
509
+ receipt.recovery.validation.readiness = receipt.readiness;
510
+ }
511
+ break;
512
+ }
513
+ case 'transport':
514
+ receipt.authentication.transportStatus = numberAt(0, 'HTTP status');
515
+ break;
516
+ case 'launch-url':
517
+ receipt.authentication.launchUrlObserved = true;
518
+ break;
519
+ case 'auth-exchange':
520
+ receipt.authentication.exchangeStatus = numberAt(0, 'HTTP status');
521
+ break;
522
+ case 'authenticated':
523
+ receipt.authentication.authenticatedStatus = numberAt(0, 'HTTP status');
524
+ break;
525
+ case 'browser-handoff': {
526
+ const outcome = args[0];
527
+ if (outcome !== 'acknowledged' && outcome !== 'off' && outcome !== 'failed')
528
+ throw new Error('browser-handoff: invalid outcome');
529
+ if (outcome === 'acknowledged') {
530
+ const channel = args[1];
531
+ const authentication = args[2];
532
+ const authority = args[3];
533
+ if ((channel !== 'original-tab' && channel !== 'fallback-tab')
534
+ || (authentication !== 'existing-cookie' && authentication !== 'launch-url')
535
+ || authority === undefined || authority === '') {
536
+ throw new Error('browser-handoff: invalid acknowledgement evidence');
537
+ }
538
+ if (receipt.readiness === undefined)
539
+ throw new Error('browser-handoff: server readiness is not proven');
540
+ if (receipt.readiness.role === 'target' && receipt.targetValidation?.canary?.outcome !== 'pass') {
541
+ throw new Error('browser-handoff: target canary has not passed');
542
+ }
543
+ if (receipt.readiness.role === 'previous' && receipt.recovery.validation?.canary === undefined) {
544
+ throw new Error('browser-handoff: restored-previous canary has not settled');
545
+ }
546
+ receipt.browserHandoff = {
547
+ required: true,
548
+ status: 'acknowledged',
549
+ channel,
550
+ authentication,
551
+ authority,
552
+ acknowledgedAt: now,
553
+ };
554
+ }
555
+ else {
556
+ receipt.browserHandoff = { required: outcome !== 'off', status: outcome };
557
+ }
558
+ receipt.authentication.browserHandoff = receipt.browserHandoff.status;
559
+ break;
560
+ }
561
+ case 'browser-fallback-opened':
562
+ if (!receipt.browserHandoff.required)
563
+ throw new Error('browser-fallback-opened: handoff is disabled');
564
+ if (receipt.readiness?.role === 'target' && receipt.targetValidation?.canary?.outcome !== 'pass') {
565
+ throw new Error('browser-fallback-opened: target canary has not passed');
566
+ }
567
+ if (receipt.readiness?.role === 'previous' && receipt.recovery.validation?.canary === undefined) {
568
+ throw new Error('browser-fallback-opened: restored-previous canary has not settled');
569
+ }
570
+ receipt.browserHandoff = { required: true, status: 'fallback-opened' };
571
+ receipt.authentication.browserHandoff = 'fallback-opened';
572
+ break;
573
+ case 'control-requested': {
574
+ const action = args[0];
575
+ if (action !== 'abort' && action !== 'restore-previous')
576
+ throw new Error('control-requested: invalid action');
577
+ receipt.recovery.detail = action === 'restore-previous'
578
+ ? 'operator explicitly requested restoration of the previous complete launch specification'
579
+ : `operator aborted the cutover; applying pre-approved ${receipt.recovery.policy} policy`;
580
+ break;
581
+ }
582
+ case 'attempt-failed': {
583
+ const role = args[0];
584
+ if (role !== 'target' && role !== 'previous')
585
+ throw new Error('attempt-failed: invalid role');
586
+ const attempt = numberAt(1, 'attempt');
587
+ const detail = args.slice(2).join(' ');
588
+ const found = [...receipt.attempts].reverse().find(candidate => candidate.role === role && candidate.number === attempt);
589
+ if (found !== undefined) {
590
+ found.outcome = 'failed';
591
+ if (detail !== '')
592
+ found.detail = detail;
593
+ }
594
+ receipt.failureCount ??= { target: 0, previous: 0 };
595
+ receipt.failureCount[role] += 1;
596
+ receipt.phase = role === 'target' ? 'target-retrying' : 'restoring';
597
+ receipt.recovery.detail = detail;
598
+ break;
599
+ }
600
+ case 'restoring':
601
+ if (receipt.transition !== undefined && receipt.transition.phase !== 'rolled-back') {
602
+ throw new Error('restoring: filesystem transition has not been rolled back');
603
+ }
604
+ receipt.phase = 'restoring';
605
+ receipt.recovery.detail = args.join(' ');
606
+ // Authentication belongs to one concrete launch attempt. Do not let a
607
+ // rejected target's launch URL/handoff satisfy (or block) the restored
608
+ // previous child. Attempts retain the target failure history; this
609
+ // summary is reset to describe the selected recovery side.
610
+ receipt.authentication = {
611
+ browserHandoff: receipt.authentication.browserHandoff === 'off' ? 'off' : 'pending',
612
+ };
613
+ receipt.browserHandoff = {
614
+ required: receipt.browserHandoff.required,
615
+ status: receipt.browserHandoff.required ? 'pending' : 'off',
616
+ };
617
+ rmSync(stateFile(stateDir, 'browserHandoffAck'), { force: true });
618
+ delete receipt.readiness;
619
+ delete receipt.canary;
620
+ receipt.recovery.validation = {};
621
+ {
622
+ const state = readLaunchState(stateDir);
623
+ if (state?.mode !== 'cutover' || state.cutoverId !== id)
624
+ throw new Error('restoring: cutover launch state is missing');
625
+ atomicWriteJson(stateFile(stateDir, 'launchSpec'), { ...state, selected: 'previous' });
626
+ }
627
+ break;
628
+ case 'transition': {
629
+ if (receipt.transition === undefined)
630
+ throw new Error('transition: cutover has no transition plan');
631
+ const outcome = args[0];
632
+ const planSha256 = args[1];
633
+ if (planSha256 !== receipt.transition.planSha256)
634
+ throw new Error('transition: plan digest does not match');
635
+ if (outcome === 'applied') {
636
+ if (receipt.transition.phase === 'rolled-back')
637
+ throw new Error('transition: a rolled-back plan cannot be applied');
638
+ receipt.transition = { ...receipt.transition, phase: 'applied' };
639
+ }
640
+ else if (outcome === 'rolled-back') {
641
+ receipt.transition = { ...receipt.transition, phase: 'rolled-back' };
642
+ }
643
+ else if (outcome === 'apply-failed' || outcome === 'rollback-failed') {
644
+ receipt.transition = {
645
+ ...receipt.transition,
646
+ phase: 'failed',
647
+ failureOperation: outcome === 'apply-failed' ? 'apply' : 'rollback',
648
+ ...(args.length > 2 ? { detail: args.slice(2).join(' ') } : {}),
649
+ };
650
+ }
651
+ else {
652
+ throw new Error('transition: invalid outcome');
653
+ }
654
+ break;
655
+ }
656
+ case 'canary': {
657
+ // New writers name the role. Legacy two-word events are still accepted
658
+ // and attributed to the current proven readiness role during rollout.
659
+ const explicitRole = args[0] === 'target' || args[0] === 'previous' ? args[0] : undefined;
660
+ const role = explicitRole ?? receipt.readiness?.role;
661
+ const outcomeIndex = explicitRole === undefined ? 0 : 1;
662
+ const outcome = args[outcomeIndex];
663
+ if ((role !== 'target' && role !== 'previous')
664
+ || (outcome !== 'pass' && outcome !== 'fail' && outcome !== 'skipped')) {
665
+ throw new Error('canary: invalid role or outcome');
666
+ }
667
+ if (role === 'target' && outcome === 'skipped')
668
+ throw new Error('canary: target canary cannot be skipped');
669
+ const detail = args.slice(outcomeIndex + 1).join(' ');
670
+ const canary = {
671
+ outcome,
672
+ ...(detail === '' ? {} : { detail }),
673
+ };
674
+ if (role === 'target') {
675
+ receipt.targetValidation ??= {};
676
+ receipt.targetValidation.canary = canary;
677
+ // Compatibility view for a successful target receipt. Recovery clears
678
+ // it so target failure cannot masquerade as previous readiness.
679
+ receipt.canary = canary;
680
+ }
681
+ else {
682
+ receipt.recovery.validation ??= {};
683
+ receipt.recovery.validation.canary = canary;
684
+ delete receipt.canary;
685
+ }
686
+ break;
687
+ }
688
+ case 'ready': {
689
+ const role = args[0];
690
+ if (role !== 'target' && role !== 'previous')
691
+ throw new Error('ready: invalid role');
692
+ const state = readLaunchState(stateDir);
693
+ if (state?.mode !== 'cutover' || state.cutoverId !== id)
694
+ throw new Error('ready: cutover launch state is missing');
695
+ const ownership = role === 'target' ? receipt.ownership?.target : receipt.ownership?.restored;
696
+ if (ownership === undefined || receipt.readiness?.role !== role
697
+ || receipt.readiness.childPid !== ownership.childPid
698
+ || receipt.readiness.listenerPid !== ownership.listenerPid
699
+ || receipt.readiness.retryCount !== 0) {
700
+ throw new Error(`ready: ${role} child/listener ownership was not stable and proven`);
701
+ }
702
+ const found = [...receipt.attempts].reverse().find(candidate => candidate.role === role && candidate.outcome === undefined);
703
+ if (found === undefined || found.childPid !== ownership.childPid
704
+ || found.childStartToken !== ownership.childStartToken) {
705
+ throw new Error(`ready: ${role} ownership does not match the active attempt`);
706
+ }
707
+ if (role === 'target' && receipt.targetValidation?.canary?.outcome !== 'pass') {
708
+ throw new Error('ready: target canary has not passed');
709
+ }
710
+ if (role === 'previous' && receipt.recovery.validation?.canary === undefined) {
711
+ throw new Error('ready: restored-previous canary has not settled');
712
+ }
713
+ if (role === 'target' && receipt.transition !== undefined && receipt.transition.phase !== 'applied') {
714
+ throw new Error('ready: target transition is not applied');
715
+ }
716
+ if (role === 'previous' && receipt.transition !== undefined && receipt.transition.phase !== 'rolled-back') {
717
+ throw new Error('ready: previous transition is not rolled back');
718
+ }
719
+ if (receipt.authentication.launchUrlObserved === true && receipt.browserHandoff.required
720
+ && receipt.browserHandoff.status !== 'acknowledged') {
721
+ throw new Error('ready: authenticated launch URL has no browser acknowledgement');
722
+ }
723
+ found.outcome = 'ready';
724
+ if (role === 'target') {
725
+ receipt.phase = 'ready';
726
+ receipt.recovery.result = 'not-needed';
727
+ terminal = 'target-ready';
728
+ }
729
+ else {
730
+ receipt.phase = 'restored';
731
+ receipt.recovery.result = 'restored';
732
+ terminal = 'restored';
733
+ }
734
+ if (receipt.authentication.browserHandoff === 'pending'
735
+ && receipt.authentication.launchUrlObserved !== true) {
736
+ receipt.authentication.browserHandoff = 'not-required';
737
+ receipt.browserHandoff = { required: receipt.browserHandoff.required, status: 'not-required' };
738
+ }
739
+ // Keep the full pair until AFTER the terminal receipt rename releases
740
+ // the wake gate. Compacting state first would make cutoverBlocksWake see
741
+ // "stable" for a few disk operations before the report/receipt existed.
742
+ stableSpec = state[role];
743
+ atomicWriteJson(stateFile(stateDir, 'instanceLaunch'), {
744
+ command: state[role].command,
745
+ source: 'supervisor',
746
+ supervised: true,
747
+ port: state[role].port,
748
+ recordedAt: now,
749
+ });
750
+ break;
751
+ }
752
+ case 'awaiting-user':
753
+ receipt.phase = 'awaiting-user';
754
+ receipt.recovery.result = 'waiting-for-user';
755
+ receipt.recovery.detail = args.join(' ');
756
+ terminal = 'awaiting-user';
757
+ break;
758
+ case 'prepare-failed': {
759
+ const state = readLaunchState(stateDir);
760
+ if (state?.mode === 'cutover' && state.cutoverId === id) {
761
+ stableSpec = state.previous;
762
+ }
763
+ receipt.phase = 'prepare-failed';
764
+ receipt.recovery.result = 'prepare-failed';
765
+ receipt.recovery.detail = args.join(' ');
766
+ terminal = 'prepare-failed';
767
+ break;
768
+ }
769
+ default:
770
+ throw new Error(`unknown cutover event ${kind}`);
771
+ }
772
+ appendEvent(receipt, kind, args.join(' '), now);
773
+ // Write the report outcome before releasing the wake gate in the terminal
774
+ // receipt rename: the restarted plugin must never observe "ready" with only
775
+ // the exit agent's older, context-free record.
776
+ if (terminal !== undefined)
777
+ updateRestartRecord(stateDir, receipt, terminal);
778
+ atomicWriteJson(stateFile(stateDir, 'launchCutover'), receipt);
779
+ if (terminal !== undefined)
780
+ clearCutoverControl(stateDir, id);
781
+ if (terminal !== undefined) {
782
+ // Keep hashed per-tab registrations after a successful terminal event so
783
+ // slower registered tabs can still recover through this exact final
784
+ // listener. A new cutover removes the registry before arming its own tabs.
785
+ if (terminal !== 'target-ready' && terminal !== 'restored') {
786
+ rmSync(stateFile(stateDir, 'browserHandoffRequest'), { force: true });
787
+ }
788
+ rmSync(stateFile(stateDir, 'browserHandoffAck'), { force: true });
789
+ }
790
+ if (stableSpec !== undefined) {
791
+ atomicWriteJson(stateFile(stateDir, 'launchSpec'), { version: 1, mode: 'stable', active: stableSpec });
792
+ }
793
+ return receipt;
794
+ }
795
+ /** A restarted instance must not wake/report while the watchdog is still proving the cutover. */
796
+ export function cutoverBlocksWake(stateDir) {
797
+ const state = readLaunchState(stateDir);
798
+ // The receipt is intentionally written before the selected-state commit.
799
+ // A crash in that gap leaves an orphan prepared receipt but the previous
800
+ // stable spec authoritative; it must not gate every later cold start.
801
+ if (state?.mode !== 'cutover')
802
+ return false;
803
+ const receipt = readCutoverReceipt(stateDir);
804
+ // Once launch state says cutover, a missing/mismatched receipt is corruption:
805
+ // fail closed rather than waking a session against an unproved process.
806
+ if (receipt === null || receipt.id !== state.cutoverId)
807
+ return true;
808
+ const terminal = ['ready', 'restored', 'awaiting-user', 'prepare-failed'];
809
+ return !terminal.includes(receipt.phase);
810
+ }
811
+ /** The active transaction's full specs plus its redacted policy receipt. */
812
+ export function activeCutover(stateDir) {
813
+ const state = readLaunchState(stateDir);
814
+ const receipt = readCutoverReceipt(stateDir);
815
+ if (state?.mode !== 'cutover' || receipt === null || receipt.id !== state.cutoverId)
816
+ return null;
817
+ // A crash in the tiny receipt-terminal → state-compaction gap is safe to
818
+ // treat as ordinary selected-spec startup; only awaiting-user intentionally
819
+ // retains a terminal cutover state.
820
+ if (receipt.phase === 'ready' || receipt.phase === 'restored' || receipt.phase === 'prepare-failed')
821
+ return null;
822
+ return { state, receipt };
823
+ }