@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +33 -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 +2349 -182
  6. package/lib/client.js +344 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +746 -40
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/restart-context-DUyExi9O.js +1245 -0
  12. package/lib/restart-context-DlaDZWKq.js +1250 -0
  13. package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
  14. package/lib/test-seam-cli.js +24 -0
  15. package/lib/test-seam-dwvaKjRp.js +459 -0
  16. package/lib/test-seam.js +2 -0
  17. package/lib/types/browser-handoff.d.ts +55 -0
  18. package/lib/types/browser-handoff.js +489 -0
  19. package/lib/types/cli.d.ts +34 -4
  20. package/lib/types/cli.js +1421 -166
  21. package/lib/types/client/index.d.ts +15 -0
  22. package/lib/types/client/index.js +348 -0
  23. package/lib/types/deployment-proof.d.ts +24 -0
  24. package/lib/types/deployment-proof.js +314 -0
  25. package/lib/types/exit-agent.js +2 -0
  26. package/lib/types/git.d.ts +12 -3
  27. package/lib/types/git.js +69 -7
  28. package/lib/types/index.d.ts +66 -3
  29. package/lib/types/index.js +192 -41
  30. package/lib/types/launch-spec.d.ts +263 -0
  31. package/lib/types/launch-spec.js +823 -0
  32. package/lib/types/preflight-runner.d.ts +23 -12
  33. package/lib/types/preflight-runner.js +152 -57
  34. package/lib/types/processes.d.ts +38 -6
  35. package/lib/types/processes.js +236 -10
  36. package/lib/types/restart-context.d.ts +50 -0
  37. package/lib/types/restart-context.js +117 -6
  38. package/lib/types/restart-request.d.ts +32 -0
  39. package/lib/types/restart-request.js +128 -0
  40. package/lib/types/state-files.d.ts +30 -0
  41. package/lib/types/state-files.js +55 -0
  42. package/lib/types/state.d.ts +29 -2
  43. package/lib/types/state.js +52 -7
  44. package/lib/types/temp-artifact.d.ts +15 -0
  45. package/lib/types/temp-artifact.js +17 -0
  46. package/lib/types/test-seam-cli.d.ts +3 -0
  47. package/lib/types/test-seam-cli.js +27 -0
  48. package/lib/types/test-seam.d.ts +55 -0
  49. package/lib/types/test-seam.js +112 -0
  50. package/lib/types/transition.d.ts +118 -0
  51. package/lib/types/transition.js +717 -0
  52. package/package.json +30 -11
  53. package/scripts/dsh-watchdog.sh +1388 -80
  54. package/scripts/install-launchd.sh +43 -5
  55. package/scripts/install-systemd.sh +43 -5
  56. package/scripts/on-install.js +1 -1
  57. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  58. package/lib/processes-hCAmwma-.js +0 -127
  59. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -1,16 +1,65 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { connect } from 'node:net';
3
3
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
4
- import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets';
5
- import { execFileSync } from 'node:child_process';
4
+ // Namespace handle for runtime feature detection: the 0.1.2 host replaced
5
+ // the `resolveSessionPreset` free function (and the `PresetBearingSession`
6
+ // type) with the `agentPresetProjectionDefinition` unit, and a STATIC named
7
+ // import of a removed export is a SyntaxError at module load — exactly the
8
+ // failure this dual-host probing exists to survive. The derivation below
9
+ // reads both surfaces through one structural cast.
10
+ import * as agentPresetsHost from '@deepseek-ai/dsh-agent-presets';
6
11
  import { existsSync, readFileSync, unlinkSync } from 'node:fs';
7
12
  import { dirname, join } from 'node:path';
8
13
  import { fileURLToPath } from 'node:url';
9
14
  import { resolveRepoDir, resolveStateDir, SRC_ARTIFACT_PATTERN } from "./defaults.js";
10
- import { commitCheckpoint, currentHead, resetToCheckpoint } from "./git.js";
15
+ import { commitCheckpoint, currentHead, isWorkingTreeClean, resetToCheckpoint } from "./git.js";
16
+ import { listeningPortsForPid } from "./processes.js";
17
+ import { cutoverBlocksWake } from "./launch-spec.js";
11
18
  import { stateFile } from "./state-files.js";
12
- import { acknowledgeRestartRecord, buildLaunchCommand, continueAndReportText, continueInterruptedText, interruptedSnapshotFile, writeInstanceLaunch, writeSkillRegistration, pendingRestartRecord, readInterruptedSnapshot, restartContextText, writeInterruptedSnapshot, } from "./restart-context.js";
19
+ import { registerBrowserHandoff } from "./browser-handoff.js";
20
+ import { requestRestart } from "./restart-request.js";
21
+ import { acknowledgeRestartRecord, buildLaunchCommand, continueAndReportText, continueInterruptedText, interruptedSnapshotFile, isParkedOnUserInput, writeInstanceLaunch, writeSkillRegistration, pendingRestartRecord, readInterruptedSnapshot, restartContextText, writeInterruptedSnapshot, } from "./restart-context.js";
13
22
  import { clearCredential, loadState, recordCredential, setCheckpoint, verifyCredential, } from "./state.js";
23
+ /**
24
+ * Which preset a session actually runs, newest selection winning. The rc host
25
+ * exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
26
+ * replaced it with `agentPresetProjectionDefinition` (init from the header,
27
+ * fold `agent-preset/selected` events) — same semantics, so the derivation
28
+ * feature-detects either surface and never touches the version string. A host
29
+ * with neither yields undefined: the resume falls back to the deployment's
30
+ * default preset, the same outcome a preset-less session had before.
31
+ * @param host - the agent-presets module namespace, structurally probed.
32
+ * @param session - the session's header and event log.
33
+ * @returns the preset id, or `undefined` when the session names none.
34
+ */
35
+ export function deriveSessionPreset(host, session) {
36
+ if (host.agentPresetProjectionDefinition !== undefined) {
37
+ const projection = host.agentPresetProjectionDefinition;
38
+ let state = projection.init(session.header);
39
+ for (const event of session.events)
40
+ state = projection.apply(state, event);
41
+ return state ?? undefined;
42
+ }
43
+ return host.resolveSessionPreset?.(session);
44
+ }
45
+ /** Probe the session-persistence service's cold-read face. */
46
+ function probeColdReader(ctx) {
47
+ const service = ctx.get('sessionPersistence');
48
+ if (service === undefined || service === null)
49
+ return undefined;
50
+ return typeof service.open === 'function' ? service : undefined;
51
+ }
52
+ /** Read one persisted session's header and full log through a read handle. */
53
+ async function readColdLog(persistence, id) {
54
+ const handle = await persistence.open(id, 'read');
55
+ try {
56
+ const cold = await handle.read(0);
57
+ return { meta: handle.header, events: cold.events };
58
+ }
59
+ finally {
60
+ await handle.close();
61
+ }
62
+ }
14
63
  export const Config = z.object({
15
64
  maxAgeMinutes: z.natural().min(1).default(10),
16
65
  stateDir: z.string().default(''),
@@ -56,7 +105,13 @@ function registerRestartSkill(ctx, stateDir) {
56
105
  writeSkillRegistration(stateDir, { registered: false, reason: 'shipped SKILL.md malformed', at: Date.now() });
57
106
  return;
58
107
  }
59
- ctx.effect(() => skills.register({ name, description, content, source: 'runtime' }));
108
+ ctx.effect(() => skills.register({
109
+ name,
110
+ description,
111
+ content,
112
+ source: 'runtime',
113
+ provider: 'ankh-guard',
114
+ }));
60
115
  writeSkillRegistration(stateDir, { registered: true, at: Date.now() });
61
116
  }
62
117
  catch (error) {
@@ -119,6 +174,7 @@ export function apply(ctx, config) {
119
174
  // (default-on!) with no warning — a misconfiguration failing silent.
120
175
  const followupReport = reportMode === 'followup';
121
176
  registerRestartSkill(ctx, stateDir);
177
+ registerBrowserHandoff(ctx, stateDir);
122
178
  if (followupReport || resumeInterrupted) {
123
179
  const pluginMessage = (text) => createUserMessage({
124
180
  content: [{ type: 'text', text }],
@@ -129,6 +185,33 @@ export function apply(ctx, config) {
129
185
  const pendingContinue = new Map();
130
186
  let disposed = false;
131
187
  ctx.effect(() => () => { disposed = true; });
188
+ // A turn parked on user input (an open ask_user_question call, or an
189
+ // undecided approval) is not interrupted WORK — the card persists in the
190
+ // log and the user answers whenever. Auto-continuing it replays the
191
+ // question and burns a turn for nothing (observed on prod 3080: sessions
192
+ // parked on question cards were woken on every restart of an upgrade
193
+ // day). The probe reads the session's repaired log tail; memoized per
194
+ // boot; a probe failure fails open to the pre-existing behavior.
195
+ const parkedMemo = new Map();
196
+ const checkParked = (id) => {
197
+ let probe = parkedMemo.get(id);
198
+ if (probe === undefined) {
199
+ probe = (async () => {
200
+ try {
201
+ const persistence = probeColdReader(ctx);
202
+ if (persistence === undefined)
203
+ return false;
204
+ const { events } = await readColdLog(persistence, id);
205
+ return isParkedOnUserInput(events);
206
+ }
207
+ catch {
208
+ return false;
209
+ }
210
+ })();
211
+ parkedMemo.set(id, probe);
212
+ }
213
+ return probe;
214
+ };
132
215
  const claim = (agent, record) => {
133
216
  const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
134
217
  const text = restartContextText(record, canaryPending);
@@ -142,33 +225,77 @@ export function apply(ctx, config) {
142
225
  };
143
226
  // The single delivery path for every resume trigger (this plugin's pass,
144
227
  // the UI, the schedule system): an interrupted session gets exactly one
145
- // "continue" injection; the restart's initiator gets the report — merged
146
- // into one message when it is both. The map makes repeat calls no-ops.
228
+ // "continue" injection — unless its interrupted turn was parked on user
229
+ // input, in which case the card in the log is the continuation and no
230
+ // injection fires; the restart's initiator gets the report — merged into
231
+ // one message when it is both. The map makes repeat calls no-ops.
147
232
  const deliver = (agent) => {
233
+ // Transport can be up before the watchdog has exchanged the launch URL,
234
+ // handed it to the browser, or run the canary. Do not let that early
235
+ // mount wake a session; the release poll below retries live roots once
236
+ // the durable receipt reaches a terminal phase.
237
+ if (cutoverBlocksWake(stateDir))
238
+ return;
148
239
  const id = agent.id;
149
240
  const exitAt = pendingContinue.get(id);
150
- const record = followupReport ? pendingRestartRecord(stateDir) : null;
151
- if (exitAt !== undefined && record !== null
152
- && (record.initiator === undefined || id === record.initiator)) {
153
- // The initiator was itself interrupted by its own restart: one
154
- // combined turn continues the work AND reports the outcome — two
155
- // separate injections would run two near-duplicate turns.
156
- const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
157
- const text = continueAndReportText(record, canaryPending);
158
- if (text !== '') {
159
- agent.followup(pluginMessage(text));
160
- pendingContinue.delete(id);
161
- acknowledgeRestartRecord(stateDir, record, Date.now());
162
- return;
163
- }
164
- // A record with nothing to report yet (no exitAt/error) must not
165
- // swallow the continue — fall through to the continue-only path.
241
+ let record = followupReport ? pendingRestartRecord(stateDir) : null;
242
+ // A bare PLANNED outcome with no initiating session (the restart was
243
+ // driven from outside the host — the operator's terminal already has
244
+ // the announcement) has no in-host owner: settle the record instead of
245
+ // waking whichever root session happens to mount first. Records
246
+ // carrying diagnostics someone must hear about — an unplanned
247
+ // recovery, a composition rollback, a failed restart — keep the
248
+ // first-created claim.
249
+ if (record !== null && record.initiator === undefined
250
+ && record.unexpected !== true && record.compositionRecovered !== true && record.error === undefined) {
251
+ acknowledgeRestartRecord(stateDir, record, Date.now());
252
+ record = null;
166
253
  }
254
+ const owesReport = record !== null && (record.initiator === undefined || id === record.initiator);
167
255
  if (exitAt !== undefined) {
168
- agent.followup(pluginMessage(continueInterruptedText(exitAt)));
169
- // Delete only after a successful injection: a throwing followup keeps
170
- // the session eligible at its next creation (same rule as claim()).
171
- pendingContinue.delete(id);
256
+ void (async () => {
257
+ const parked = await checkParked(id);
258
+ if (disposed || pendingContinue.get(id) !== exitAt)
259
+ return;
260
+ // Delete before injecting: two resume triggers racing the memoized
261
+ // probe must not double-inject; a throwing followup re-arms the
262
+ // session for its next creation (same rule as claim()).
263
+ pendingContinue.delete(id);
264
+ if (parked) {
265
+ // Parked on user input: the card IS the continuation. An owed
266
+ // report still lands (report-only text, not the merged one).
267
+ if (owesReport && record !== null)
268
+ claim(agent, record);
269
+ return;
270
+ }
271
+ if (owesReport && record !== null) {
272
+ // The initiator was itself interrupted by its own restart: one
273
+ // combined turn continues the work AND reports the outcome — two
274
+ // separate injections would run two near-duplicate turns.
275
+ const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
276
+ const text = continueAndReportText(record, canaryPending);
277
+ if (text !== '') {
278
+ try {
279
+ agent.followup(pluginMessage(text));
280
+ acknowledgeRestartRecord(stateDir, record, Date.now());
281
+ return;
282
+ }
283
+ catch {
284
+ pendingContinue.set(id, exitAt);
285
+ return;
286
+ }
287
+ }
288
+ // A record with nothing to report yet (no exitAt/error) must not
289
+ // swallow the continue — fall through to the continue-only path.
290
+ }
291
+ try {
292
+ agent.followup(pluginMessage(continueInterruptedText(exitAt)));
293
+ }
294
+ catch {
295
+ pendingContinue.set(id, exitAt);
296
+ }
297
+ })();
298
+ return;
172
299
  }
173
300
  if (record === null)
174
301
  return;
@@ -229,10 +356,13 @@ export function apply(ctx, config) {
229
356
  agentOptions.model = selection.model;
230
357
  let setup;
231
358
  const presets = ctx.get('agentPresets');
232
- const persistence = ctx.get('sessionPersistence');
359
+ const persistence = probeColdReader(ctx);
233
360
  if (presets !== undefined && persistence !== undefined) {
234
- const inspected = await persistence.inspect(id);
235
- const presetId = resolveSessionPreset({ header: inspected.meta, events: inspected.events });
361
+ const inspected = await readColdLog(persistence, id);
362
+ const presetId = deriveSessionPreset(agentPresetsHost, {
363
+ header: inspected.meta,
364
+ events: inspected.events,
365
+ });
236
366
  setup = async (agentCtx) => { await presets.mount(agentCtx, (await presets.resolve(presetId)).id); };
237
367
  }
238
368
  return { resumeSessionId: id, agentOptions, ...(setup === undefined ? {} : { setup }) };
@@ -260,6 +390,11 @@ export function apply(ctx, config) {
260
390
  // happens through `deliver` below, from the `agent/created` listener or
261
391
  // the live branch here.
262
392
  const resumePass = async () => {
393
+ while (!disposed && cutoverBlocksWake(stateDir)) {
394
+ await new Promise(resolve => setTimeout(resolve, 250));
395
+ }
396
+ if (disposed)
397
+ return;
263
398
  const snapshot = readInterruptedSnapshot(stateDir);
264
399
  if (snapshot === null)
265
400
  return;
@@ -269,6 +404,13 @@ export function apply(ctx, config) {
269
404
  for (const id of [...new Set([...snapshot.resume, ...snapshot.interrupted])]) {
270
405
  if (disposed)
271
406
  return;
407
+ // Parked on user input: the card in the log is the continuation —
408
+ // do not recreate the agent at all (its creation would fire the
409
+ // delivery path; deliver() also filters, belt and suspenders).
410
+ if (await checkParked(id)) {
411
+ pendingContinue.delete(id);
412
+ continue;
413
+ }
272
414
  const live = ctx.agents.list().find(agent => agent.id === id);
273
415
  if (live !== undefined) {
274
416
  // Already live: its `agent/created` may have predated this plugin's
@@ -297,6 +439,19 @@ export function apply(ctx, config) {
297
439
  const timer = setTimeout(() => { void resumePass(); }, resumeDelayMs);
298
440
  ctx.effect(() => () => { clearTimeout(timer); });
299
441
  }
442
+ // The browser handoff can create an agent while the receipt still says
443
+ // canary-pending. agent/created is one-shot, so retry already-live roots
444
+ // after release; acknowledgement and pendingContinue preserve exact-once.
445
+ if (cutoverBlocksWake(stateDir)) {
446
+ const releaseTimer = setInterval(() => {
447
+ if (disposed || cutoverBlocksWake(stateDir))
448
+ return;
449
+ clearInterval(releaseTimer);
450
+ for (const agent of ctx.agents.roots())
451
+ deliver(agent);
452
+ }, 250);
453
+ ctx.effect(() => () => { clearInterval(releaseTimer); });
454
+ }
300
455
  ctx.on('agent/created', ({ agent }) => {
301
456
  if (!ctx.agents.roots().includes(agent))
302
457
  return;
@@ -313,6 +468,8 @@ export function apply(ctx, config) {
313
468
  const record = pendingRestartRecord(stateDir);
314
469
  if (record === null)
315
470
  return decision;
471
+ if (cutoverBlocksWake(stateDir))
472
+ return decision;
316
473
  const canaryPending = existsSync(stateFile(stateDir, 'restartRequested'));
317
474
  const text = restartContextText(record, canaryPending);
318
475
  if (text === '')
@@ -334,11 +491,13 @@ export function apply(ctx, config) {
334
491
  }, { prepend: true });
335
492
  }
336
493
  const service = {
337
- verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes),
494
+ verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir)),
338
495
  record: (scope, options) => {
339
496
  const head = currentHead(repoDir);
340
497
  if (head === null)
341
498
  throw new Error('ankh-guard: cannot record a credential outside a git repository');
499
+ if (!isWorkingTreeClean(repoDir))
500
+ throw new Error('ankh-guard: cannot record a credential while the working tree is dirty');
342
501
  return recordCredential(stateDir, { scope, revision: head, command: options?.command ?? '' }, Date.now());
343
502
  },
344
503
  clear: () => clearCredential(stateDir, Date.now()),
@@ -351,6 +510,7 @@ export function apply(ctx, config) {
351
510
  return result;
352
511
  },
353
512
  reset: sha => resetToCheckpoint(repoDir, sha),
513
+ requestRestart: request => requestRestart(request, { stateDir, repoDir }),
354
514
  canary: async (options) => {
355
515
  const checks = [];
356
516
  const verdict = service.verify();
@@ -378,16 +538,7 @@ export function apply(ctx, config) {
378
538
  if (key.startsWith('DSH_') && value !== undefined)
379
539
  env[key] = value;
380
540
  }
381
- let port;
382
- try {
383
- const out = execFileSync('lsof', ['-a', '-p', String(process.pid), '-iTCP', '-sTCP:LISTEN', '-P'], { encoding: 'utf8', stdio: 'pipe' });
384
- const match = /:(\d+) \(LISTEN\)/.exec(out);
385
- if (match !== null)
386
- port = Number(match[1]);
387
- }
388
- catch {
389
- // lsof unavailable or nothing listening yet — the record still helps.
390
- }
541
+ const port = listeningPortsForPid(process.pid)[0];
391
542
  writeInstanceLaunch(stateDir, {
392
543
  command: buildLaunchCommand(process.execPath, process.execArgv, process.argv.slice(1), process.cwd(), env),
393
544
  source: 'instance',
@@ -0,0 +1,263 @@
1
+ import type { TransitionReference } from './transition.ts';
2
+ export type CutoverRecoveryPolicy = 'restore-previous' | 'wait-for-user';
3
+ export type BrowserHandoffPolicy = 'required' | 'off';
4
+ export type LaunchRole = 'target' | 'previous';
5
+ export type CutoverControlAction = 'abort' | 'restore-previous';
6
+ export type PreflightSurface = 'source' | 'built';
7
+ /**
8
+ * Immutable execution binding for composition preflight. The runner and host
9
+ * installation are explicit because the guarded checkout and the successor's
10
+ * npm toolchain may be different module graphs.
11
+ */
12
+ export interface LaunchPreflightSpec {
13
+ version: 1;
14
+ surface: PreflightSurface;
15
+ runnerExecutable: string;
16
+ runnerRuntimeArgs: string[];
17
+ runnerPath: string;
18
+ runnerSha256: string;
19
+ /** package.json used as createRequire()'s anchor for a built successor. */
20
+ installAnchor: string;
21
+ installAnchorSha256: string;
22
+ hostPackageVersion: string;
23
+ /** Full digest of the launch command this preflight contract approves. */
24
+ targetCommandSha256: string;
25
+ /** One-shot candidate command that validates the target CLI/argv contract. */
26
+ candidateProbeCommand?: string;
27
+ candidateProbeSha256?: string;
28
+ /** Guard binds and runs this caller assertion; it does not derive the probe. */
29
+ candidateProbeProvenance?: 'caller-supplied';
30
+ }
31
+ /** Everything needed to launch one supervised instance. The state directory is the transaction anchor. */
32
+ export interface LaunchSpec {
33
+ version: 1;
34
+ command: string;
35
+ port: number;
36
+ home: string;
37
+ /** Repository whose HEAD is bound to the guard credential and rollback. */
38
+ credentialRepo: string;
39
+ /** dsh host checkout used by preflight and exported as DSH_HARNESS. */
40
+ harnessRoot: string;
41
+ profile: string;
42
+ /** Absent only on launch state written before execution binding existed. */
43
+ preflight?: LaunchPreflightSpec;
44
+ }
45
+ export interface StableLaunchState {
46
+ version: 1;
47
+ mode: 'stable';
48
+ active: LaunchSpec;
49
+ }
50
+ export interface CutoverLaunchState {
51
+ version: 1;
52
+ mode: 'cutover';
53
+ cutoverId: string;
54
+ selected: LaunchRole;
55
+ previous: LaunchSpec;
56
+ target: LaunchSpec;
57
+ /** Immutable, cutover-scoped filesystem transition prepared before takeover. */
58
+ transition?: TransitionReference;
59
+ }
60
+ export type LaunchState = StableLaunchState | CutoverLaunchState;
61
+ export type CutoverPhase = 'prepared' | 'supervisor-starting' | 'supervisor-ready' | 'target-starting' | 'target-retrying' | 'restoring' | 'ready' | 'restored' | 'awaiting-user' | 'prepare-failed';
62
+ export interface LaunchSpecSummary {
63
+ commandSha256: string;
64
+ port: number;
65
+ home: string;
66
+ credentialRepo: string;
67
+ harnessRoot: string;
68
+ profile: string;
69
+ preflight?: {
70
+ surface: PreflightSurface;
71
+ runnerExecutable: string;
72
+ runnerRuntimeArgs: string[];
73
+ runnerPath: string;
74
+ runnerSha256: string;
75
+ installAnchor: string;
76
+ installAnchorSha256: string;
77
+ hostPackageVersion: string;
78
+ targetCommandSha256: string;
79
+ candidateProbeSha256?: string;
80
+ candidateProbeProvenance?: 'caller-supplied';
81
+ };
82
+ }
83
+ export interface CutoverAttempt {
84
+ role: LaunchRole;
85
+ number: number;
86
+ childPid: number;
87
+ childStartToken?: string;
88
+ startedAt: number;
89
+ outcome?: 'failed' | 'ready';
90
+ detail?: string;
91
+ }
92
+ /** PID identity proof safe to persist in the redacted operational receipt. */
93
+ export interface CutoverProcessOwnership {
94
+ childPid: number;
95
+ childStartToken: string;
96
+ listenerPid: number;
97
+ listenerStartToken: string;
98
+ }
99
+ export interface CutoverBrowserHandoff {
100
+ required: boolean;
101
+ status: 'pending' | 'acknowledged' | 'fallback-opened' | 'off' | 'failed' | 'not-required';
102
+ channel?: 'original-tab' | 'fallback-tab';
103
+ authentication?: 'existing-cookie' | 'launch-url';
104
+ authority?: string;
105
+ acknowledgedAt?: number;
106
+ }
107
+ export interface LaunchCutoverReceipt {
108
+ version: 1;
109
+ id: string;
110
+ phase: CutoverPhase;
111
+ preparedAt: number;
112
+ updatedAt: number;
113
+ initiator?: string;
114
+ previous: LaunchSpecSummary;
115
+ target: LaunchSpecSummary;
116
+ /** Redacted transition evidence; the plan paths live only in launch state. */
117
+ transition?: {
118
+ planSha256: string;
119
+ operationCount: number;
120
+ phase: 'pending' | 'applied' | 'rolled-back' | 'failed';
121
+ failureOperation?: 'apply' | 'rollback';
122
+ detail?: string;
123
+ };
124
+ supervisor: {
125
+ previousPid: number;
126
+ previousStartToken: string;
127
+ targetDriverPid?: number;
128
+ targetDriverStartToken?: string;
129
+ targetPid?: number;
130
+ targetStartToken?: string;
131
+ previousRetirement?: 'yielded' | 'identity-gone' | 'forced';
132
+ };
133
+ child: {
134
+ previousPid?: number;
135
+ targetPid?: number;
136
+ restoredPid?: number;
137
+ };
138
+ ownership: {
139
+ previous: CutoverProcessOwnership;
140
+ target?: CutoverProcessOwnership;
141
+ restored?: CutoverProcessOwnership;
142
+ };
143
+ readiness?: {
144
+ role: LaunchRole;
145
+ childPid: number;
146
+ listenerPid: number;
147
+ stableWindowMs: number;
148
+ retryCount: number;
149
+ };
150
+ authentication: {
151
+ transportStatus?: number;
152
+ launchUrlObserved?: boolean;
153
+ exchangeStatus?: number;
154
+ authenticatedStatus?: number;
155
+ /** Compatibility summary; detailed browser evidence lives in browserHandoff. */
156
+ browserHandoff: CutoverBrowserHandoff['status'];
157
+ };
158
+ /** Browser acknowledgement is independent of server transport/readiness/canary. */
159
+ browserHandoff: CutoverBrowserHandoff;
160
+ /** Candidate and composition checks completed before the previous host stopped. */
161
+ preflight?: {
162
+ surface: PreflightSurface;
163
+ runnerExecutable: string;
164
+ runnerRuntimeArgs: string[];
165
+ runnerPath: string;
166
+ runnerSha256: string;
167
+ installAnchor: string;
168
+ installAnchorSha256: string;
169
+ hostPackageVersion: string;
170
+ targetCommandSha256: string;
171
+ candidateProbeSha256: string;
172
+ candidateProbeProvenance: 'caller-supplied';
173
+ candidateProbe: 'pass';
174
+ composition: 'pass';
175
+ };
176
+ /** Target evidence is retained even if recovery later becomes terminal. */
177
+ targetValidation?: {
178
+ readiness?: LaunchCutoverReceipt['readiness'];
179
+ canary?: {
180
+ outcome: 'pass' | 'fail';
181
+ detail?: string;
182
+ };
183
+ };
184
+ canary?: {
185
+ outcome: 'pass' | 'fail';
186
+ detail?: string;
187
+ };
188
+ attempts: CutoverAttempt[];
189
+ failureCount: {
190
+ target: number;
191
+ previous: number;
192
+ };
193
+ recovery: {
194
+ policy: CutoverRecoveryPolicy;
195
+ result: 'pending' | 'not-needed' | 'restored' | 'waiting-for-user' | 'prepare-failed';
196
+ detail?: string;
197
+ validation?: {
198
+ readiness?: LaunchCutoverReceipt['readiness'];
199
+ canary?: {
200
+ outcome: 'pass' | 'fail' | 'skipped';
201
+ detail?: string;
202
+ };
203
+ };
204
+ };
205
+ events: Array<{
206
+ at: number;
207
+ kind: string;
208
+ detail?: string;
209
+ }>;
210
+ }
211
+ export interface CutoverControlRequest {
212
+ version: 1;
213
+ cutoverId: string;
214
+ action: CutoverControlAction;
215
+ requestedAt: number;
216
+ }
217
+ export declare function commandSha256(command: string): string;
218
+ /** Read the selected launch state, or null for a deployment predating this protocol. */
219
+ export declare function readLaunchState(stateDir: string): LaunchState | null;
220
+ /** The exact spec a newly starting supervisor must honor. */
221
+ export declare function selectedLaunchSpec(state: LaunchState): LaunchSpec;
222
+ /** Persist an ordinary, non-transactional active spec. */
223
+ export declare function writeStableLaunchSpec(stateDir: string, spec: LaunchSpec, ifAbsent?: boolean): boolean;
224
+ export declare function summarizeLaunchSpec(spec: LaunchSpec): LaunchSpecSummary;
225
+ /** Safe operator view: preserve topology and selection without printing commands. */
226
+ export declare function summarizeLaunchState(state: LaunchState | null): unknown;
227
+ /** Read the durable receipt, or null when absent/malformed. */
228
+ export declare function readCutoverReceipt(stateDir: string): LaunchCutoverReceipt | null;
229
+ /** Persist an operator control request for the current watchdog to consume. */
230
+ export declare function writeCutoverControl(stateDir: string, cutoverId: string, action: CutoverControlAction, now: number): CutoverControlRequest;
231
+ export declare function readCutoverControl(stateDir: string): CutoverControlRequest | null;
232
+ export declare function clearCutoverControl(stateDir: string, cutoverId: string): void;
233
+ /**
234
+ * Prepare one cutover. The receipt lands first; the single launch-state rename
235
+ * is the commit point selecting target. A crash before that rename leaves the
236
+ * previous stable state authoritative and the old supervisor untouched.
237
+ */
238
+ export declare function prepareLaunchCutover(stateDir: string, input: {
239
+ id: string;
240
+ previous: LaunchSpec;
241
+ target: LaunchSpec;
242
+ recoveryPolicy: CutoverRecoveryPolicy;
243
+ browserHandoff: BrowserHandoffPolicy;
244
+ previousSupervisorPid: number;
245
+ previousSupervisorStartToken: string;
246
+ previousOwnership: CutoverProcessOwnership;
247
+ transition?: TransitionReference;
248
+ initiator?: string;
249
+ now: number;
250
+ }): LaunchCutoverReceipt;
251
+ /**
252
+ * Apply one watchdog event. Arguments are deliberately credential-free; the
253
+ * launch URL itself never crosses this boundary or lands in the receipt.
254
+ */
255
+ export declare function recordCutoverEvent(stateDir: string, id: string, kind: string, args: readonly string[], now: number): LaunchCutoverReceipt;
256
+ /** A restarted instance must not wake/report while the watchdog is still proving the cutover. */
257
+ export declare function cutoverBlocksWake(stateDir: string): boolean;
258
+ /** The active transaction's full specs plus its redacted policy receipt. */
259
+ export declare function activeCutover(stateDir: string): {
260
+ state: CutoverLaunchState;
261
+ receipt: LaunchCutoverReceipt;
262
+ } | null;
263
+ //# sourceMappingURL=launch-spec.d.ts.map