@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.
- package/CHANGELOG.md +28 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2383 -209
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +722 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1487 -225
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +157 -39
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +106 -0
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +29 -9
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
|
@@ -0,0 +1,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
|
+
}
|