@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.
- package/CHANGELOG.md +33 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2349 -182
- package/lib/client.js +344 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +746 -40
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/restart-context-DlaDZWKq.js +1250 -0
- package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1421 -166
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +348 -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 +192 -41
- 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 +117 -6
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +30 -11
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/types/index.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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 {
|
|
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({
|
|
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
|
|
146
|
-
//
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
359
|
+
const persistence = probeColdReader(ctx);
|
|
233
360
|
if (presets !== undefined && persistence !== undefined) {
|
|
234
|
-
const inspected = await persistence
|
|
235
|
-
const presetId =
|
|
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
|
-
|
|
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
|