@khorsheed/dsh-ankh-guard 0.4.1 → 0.4.3
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 +11 -0
- package/README.en.md +1 -1
- package/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/lib/cli.js +6 -3
- package/lib/client.js +21 -1
- package/lib/{defaults-CduvOcGq.js → defaults-DX6GVMvK.js} +2 -2
- package/lib/exit-agent.js +1 -1
- package/lib/index.js +17 -13
- package/lib/preflight-runner.js +1 -1
- package/lib/{restart-context-DlaDZWKq.js → restart-context-BYN4zJsu.js} +321 -2
- package/lib/test-seam-cli.js +0 -0
- package/lib/types/browser-handoff.d.ts +1 -1
- package/lib/types/browser-handoff.js +18 -11
- package/lib/types/cli.js +12 -0
- package/lib/types/client/index.js +30 -3
- package/lib/types/defaults.js +2 -2
- package/lib/types/index.js +1 -1
- package/lib/types/state.js +4 -4
- package/package.json +13 -15
- package/scripts/dsh-watchdog.sh +8 -0
- package/scripts/install-launchd.sh +0 -0
- package/scripts/install-systemd.sh +0 -0
- package/lib/invariant.js +0 -40
- package/lib/state-CZMypGkB.js +0 -323
- package/lib/types/invariant.d.ts +0 -27
- package/lib/types/invariant.js +0 -41
package/lib/types/cli.js
CHANGED
|
@@ -2203,6 +2203,12 @@ export async function runCli(argv, io) {
|
|
|
2203
2203
|
if (key.startsWith('WD_'))
|
|
2204
2204
|
delete startEnv[key];
|
|
2205
2205
|
}
|
|
2206
|
+
// NODE_PATH/NODE_OPTIONS go too: from a tool-wrapped caller (tsx pins
|
|
2207
|
+
// NODE_PATH to its own pnpm store) they hijack the instance's module
|
|
2208
|
+
// resolution onto that checkout's workspace — the watchdog's
|
|
2209
|
+
// launch_instance scrubs them for the same reason (3080, 2026-10-12).
|
|
2210
|
+
delete startEnv.NODE_PATH;
|
|
2211
|
+
delete startEnv.NODE_OPTIONS;
|
|
2206
2212
|
const child = spawn(start, {
|
|
2207
2213
|
shell: true, detached: true, stdio: 'ignore',
|
|
2208
2214
|
env: testChildEnv('restart-instance-root', startEnv, { port, tempRoot: stateDir }),
|
|
@@ -2453,6 +2459,12 @@ export async function runCli(argv, io) {
|
|
|
2453
2459
|
if (key.startsWith('WD_'))
|
|
2454
2460
|
delete supervisorBaseEnv[key];
|
|
2455
2461
|
}
|
|
2462
|
+
// NODE_PATH/NODE_OPTIONS must not reach the watchdog either: it forwards
|
|
2463
|
+
// its environment to every instance it launches, and a tool-wrapped
|
|
2464
|
+
// caller (tsx pins NODE_PATH to its own pnpm store) would otherwise
|
|
2465
|
+
// hijack instance module resolution onto that checkout (3080, 2026-10-12).
|
|
2466
|
+
delete supervisorBaseEnv.NODE_PATH;
|
|
2467
|
+
delete supervisorBaseEnv.NODE_OPTIONS;
|
|
2456
2468
|
const env = {
|
|
2457
2469
|
...supervisorBaseEnv,
|
|
2458
2470
|
WD_PORT: String(spec.port),
|
|
@@ -155,6 +155,8 @@ export function apply(_ctx) {
|
|
|
155
155
|
let timer;
|
|
156
156
|
let pending = consumeFallbackFragment() ?? readPending();
|
|
157
157
|
let cap;
|
|
158
|
+
/** Consecutive failed ACKs for the current pending handoff. */
|
|
159
|
+
let ackFailures = 0;
|
|
158
160
|
let errorRetryMs = ERROR_RETRY_MIN_MS;
|
|
159
161
|
let firstFailureAt;
|
|
160
162
|
let request;
|
|
@@ -231,6 +233,29 @@ export function apply(_ctx) {
|
|
|
231
233
|
return;
|
|
232
234
|
}
|
|
233
235
|
}
|
|
236
|
+
else {
|
|
237
|
+
// A failed ACK can be permanent: the cutover settled without this tab
|
|
238
|
+
// (operator restore, handoff off, a wedged supervisor) and the server
|
|
239
|
+
// keeps answering 409 'waiting'/'stale' for a dead cutoverId — the
|
|
240
|
+
// veil would otherwise stay up forever (observed 2026-09-30). Every
|
|
241
|
+
// few failures, spend one poll: 'idle' proves nothing is in flight,
|
|
242
|
+
// so the pending handoff is dead — drop it and let the ordinary
|
|
243
|
+
// poll loop remove the veil on the next tick.
|
|
244
|
+
ackFailures += 1;
|
|
245
|
+
if (ackFailures >= 8) {
|
|
246
|
+
ackFailures = 0;
|
|
247
|
+
const probe = await post({
|
|
248
|
+
version: 1, operation: 'poll', capability: cap ??= capability(),
|
|
249
|
+
}, current.signal);
|
|
250
|
+
if (stale())
|
|
251
|
+
return;
|
|
252
|
+
if (probe.ok && (await probe.json()).state === 'idle') {
|
|
253
|
+
sessionStorage.removeItem(PENDING_KEY);
|
|
254
|
+
sessionStorage.removeItem(CAPABILITY_KEY);
|
|
255
|
+
pending = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
234
259
|
errorRetryMs = ERROR_RETRY_MIN_MS;
|
|
235
260
|
firstFailureAt = undefined;
|
|
236
261
|
schedule(ACTIVE_RETRY_MS);
|
|
@@ -261,9 +286,9 @@ export function apply(_ctx) {
|
|
|
261
286
|
sessionStorage.setItem(BOOT_ID_KEY, result.bootId);
|
|
262
287
|
if (result.state === 'idle') {
|
|
263
288
|
document.getElementById('ankh-guard-browser-handoff')?.remove();
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
schedule();
|
|
289
|
+
// Healthy servers hold idle polls. Keep a floor for older servers or
|
|
290
|
+
// terminal states that answer immediately, so they cannot create a spin.
|
|
291
|
+
schedule(ACTIVE_RETRY_MS);
|
|
267
292
|
return;
|
|
268
293
|
}
|
|
269
294
|
if (result.state === 'waiting')
|
|
@@ -287,6 +312,7 @@ export function apply(_ctx) {
|
|
|
287
312
|
};
|
|
288
313
|
pending = nextPending;
|
|
289
314
|
writePending(nextPending);
|
|
315
|
+
ackFailures = 0;
|
|
290
316
|
waitingOverlay();
|
|
291
317
|
location.reload();
|
|
292
318
|
return;
|
|
@@ -307,6 +333,7 @@ export function apply(_ctx) {
|
|
|
307
333
|
};
|
|
308
334
|
pending = nextPending;
|
|
309
335
|
writePending(nextPending);
|
|
336
|
+
ackFailures = 0;
|
|
310
337
|
waitingOverlay();
|
|
311
338
|
// The bearer remains a local variable for the shortest possible time.
|
|
312
339
|
location.replace(launch.href);
|
package/lib/types/defaults.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Default state/repo directory resolution, shared by the cordis plugin
|
|
3
|
-
*
|
|
2
|
+
* Default state/repo directory resolution, shared by the cordis plugin and
|
|
3
|
+
* the CLI so both agree on where a credential
|
|
4
4
|
* lives. The state file must survive a restart, so it anchors to DSH_HOME (or
|
|
5
5
|
* the checkout) rather than any process-lifetime directory.
|
|
6
6
|
*/
|
package/lib/types/index.js
CHANGED
|
@@ -128,7 +128,7 @@ function registerRestartSkill(ctx, stateDir) {
|
|
|
128
128
|
description,
|
|
129
129
|
content,
|
|
130
130
|
source: 'runtime',
|
|
131
|
-
provider: 'ankh-guard',
|
|
131
|
+
provider: '@khorsheed/dsh-ankh-guard',
|
|
132
132
|
}));
|
|
133
133
|
writeSkillRegistration(stateDir, { registered: true, at: Date.now() });
|
|
134
134
|
}
|
package/lib/types/state.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* was recorded on and to a freshness window — and answers `verify()` against
|
|
6
6
|
* the CURRENT head and wall clock. A later fully gated boot may promote that
|
|
7
7
|
* credential into a deployment proof whose exact runtime fingerprint can be
|
|
8
|
-
* reused by the same-launch restart path. Framework-free: the cordis plugin
|
|
9
|
-
*
|
|
8
|
+
* reused by the same-launch restart path. Framework-free: the cordis plugin
|
|
9
|
+
* and the CLI share this module.
|
|
10
10
|
*/
|
|
11
11
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { stateFile } from "./state-files.js";
|
|
@@ -94,8 +94,8 @@ export function loadState(stateDir) {
|
|
|
94
94
|
export function saveState(stateDir, state) {
|
|
95
95
|
mkdirSync(stateDir, { recursive: true });
|
|
96
96
|
// Atomic via tmp + rename: a crash mid-write must never leave a truncated
|
|
97
|
-
// state file — a malformed one throws in loadState
|
|
98
|
-
//
|
|
97
|
+
// state file — a malformed one throws in loadState the next time the gate
|
|
98
|
+
// reads it, i.e. the guard's own state would fail every later verify.
|
|
99
99
|
const file = stateFilePath(stateDir);
|
|
100
100
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
101
101
|
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khorsheed/dsh-ankh-guard",
|
|
3
3
|
"description": "Hard gate for self-modification restarts: a green-build credential bound to the git HEAD, checked before any restart of the running instance",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -13,10 +13,6 @@
|
|
|
13
13
|
"types": "./lib/types/index.d.ts",
|
|
14
14
|
"default": "./lib/index.js"
|
|
15
15
|
},
|
|
16
|
-
"./invariant": {
|
|
17
|
-
"types": "./lib/types/invariant.d.ts",
|
|
18
|
-
"default": "./lib/invariant.js"
|
|
19
|
-
},
|
|
20
16
|
"./cli": {
|
|
21
17
|
"types": "./lib/types/cli.d.ts",
|
|
22
18
|
"default": "./lib/cli.js"
|
|
@@ -42,16 +38,18 @@
|
|
|
42
38
|
"CHANGELOG.md"
|
|
43
39
|
],
|
|
44
40
|
"license": "MIT",
|
|
41
|
+
"engines": {
|
|
42
|
+
"dsh": ">=0.1.5-rc.1 <0.3.0-0"
|
|
43
|
+
},
|
|
45
44
|
"peerDependencies": {
|
|
46
45
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
47
|
-
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
48
|
-
"@deepseek-ai/dsh-agent-preset-registry": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
49
|
-
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
50
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
51
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
52
|
-
"@deepseek-ai/dsh-
|
|
53
|
-
"@deepseek-ai/dsh-
|
|
54
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.2.0-rc.1",
|
|
46
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
|
|
47
|
+
"@deepseek-ai/dsh-agent-preset-registry": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
|
|
50
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
|
|
52
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
|
|
55
53
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
56
54
|
},
|
|
57
55
|
"peerDependenciesMeta": {
|
|
@@ -90,8 +88,8 @@
|
|
|
90
88
|
},
|
|
91
89
|
"compat": {
|
|
92
90
|
"minHost": "0.1.5-rc.1",
|
|
93
|
-
"notes": "0.1.5-rc.1 full-line boot-verified 2026-09-25 (42 packages including capture) through the three compat layers: preset-registry dual-name probe, dual-shape typert codecs, and typert faces carrying zod@4; 0.
|
|
94
|
-
"verifiedHost": "0.1
|
|
91
|
+
"notes": "0.1.5-rc.1 full-line boot-verified 2026-09-25 (42 packages including capture) through the three compat layers: preset-registry dual-name probe, dual-shape typert codecs, and typert faces carrying zod@4; 0.2.0-rc.2 is the 3080 production-verified line (watchdog-supervised gated restarts PASS — readiness, ownership window, canary, deployment proof on the 0.2.0-rc.2 release head 639ed01539 — since 2026-10-09); composition-preflight runs via the standalone preflight-runner and degrades to a notice when no live harness checkout resolves; original-tab browser handoff feature-probes optional WebServer/connection auth seams; reversible state quarantine was exercised in a live 0.1.1-rc.2 to 0.1.2-alpha.4 cutover; since 0.4.0 the preflight verdict also fails on any agent preset the registry marks broken — preset rows mount on standing scopes beside the profile tree, so a clean-booting profile whose presets cannot serve sessions (picker 加载失败, resume never-started) no longer passes the gate",
|
|
92
|
+
"verifiedHost": "0.2.1-rc.1"
|
|
95
93
|
}
|
|
96
94
|
}
|
|
97
95
|
}
|
package/scripts/dsh-watchdog.sh
CHANGED
|
@@ -182,6 +182,14 @@ launch_instance() {
|
|
|
182
182
|
if [ -n "$launch_harness_root" ]; then export DSH_HARNESS="$launch_harness_root"; fi
|
|
183
183
|
cd "$launch_home/home" 2>/dev/null || cd /tmp || exit 1
|
|
184
184
|
for v in $(env | sed -n 's/^\(WD_[^=]*\)=.*/\1/p'); do unset "$v"; done
|
|
185
|
+
# NODE_PATH/NODE_OPTIONS get the same scrub: launched from a tool-wrapped
|
|
186
|
+
# shell (tsx sets NODE_PATH to its own pnpm store), the instance's module
|
|
187
|
+
# resolution would be hijacked onto that checkout's workspace — bundle
|
|
188
|
+
# patches then resolve to the REPO's packages instead of the profile's
|
|
189
|
+
# installed ones, and the composition silently serves whatever the repo
|
|
190
|
+
# checkout happens to hold (3080, 2026-10-12: presets resolved to the main
|
|
191
|
+
# checkout's workspace copy, hiding the newly deployed preset rows).
|
|
192
|
+
unset NODE_PATH NODE_OPTIONS
|
|
185
193
|
sh -c "$start_cmd"
|
|
186
194
|
)
|
|
187
195
|
}
|
|
File without changes
|
|
File without changes
|
package/lib/invariant.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { i as resolveStateDir } from "./defaults-CduvOcGq.js";
|
|
2
|
-
import { n as loadState, o as stateFilePath } from "./state-CZMypGkB.js";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
//#region lib/types/invariant.js
|
|
5
|
-
/**
|
|
6
|
-
* Package-owned invariant companion for `@khorsheed/dsh-ankh-guard`.
|
|
7
|
-
* @module @khorsheed/dsh-ankh-guard/invariant
|
|
8
|
-
*/
|
|
9
|
-
const PACKAGE_NAME = "@khorsheed/dsh-ankh-guard";
|
|
10
|
-
/** Cordis companion plugin name. */
|
|
11
|
-
const name = "ankh-guard-invariant";
|
|
12
|
-
/** Service required before the companion can reserve package ownership. */
|
|
13
|
-
const inject = ["invariants"];
|
|
14
|
-
/**
|
|
15
|
-
* Owned-data check: the package's state file, when present at the default
|
|
16
|
-
* location, must parse as a well-formed guard state. A malformed file would
|
|
17
|
-
* make the gate unreadable — the credential's revision/recordedAt shape is
|
|
18
|
-
* this package's own contract, so corruption fails loud at load instead of at
|
|
19
|
-
* the moment a restart needs the gate.
|
|
20
|
-
* @param _ctx - host context (unused; the check reads only the state file).
|
|
21
|
-
* @param fail - invariant failure reporter.
|
|
22
|
-
*/
|
|
23
|
-
const install = (_ctx, fail) => {
|
|
24
|
-
const stateDir = resolveStateDir(void 0);
|
|
25
|
-
const file = stateFilePath(stateDir);
|
|
26
|
-
if (!existsSync(file)) return;
|
|
27
|
-
try {
|
|
28
|
-
loadState(stateDir);
|
|
29
|
-
} catch (error) {
|
|
30
|
-
fail(`state file ${file} is malformed: ${String(error)}`);
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
/**
|
|
34
|
-
* Register this package's invariant companion.
|
|
35
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
36
|
-
* @returns The installed registration's disposer after setup succeeds.
|
|
37
|
-
*/
|
|
38
|
-
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
39
|
-
//#endregion
|
|
40
|
-
export { apply, inject, install, name };
|
package/lib/state-CZMypGkB.js
DELETED
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
import { c as appendTestLifecycleEvent } from "./test-seam-dwvaKjRp.js";
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
//#region lib/types/state-files.js
|
|
5
|
-
/**
|
|
6
|
-
* The guard's state-directory protocol: every file that lives in the state
|
|
7
|
-
* dir, named exactly once. Five writers in three languages (this package's
|
|
8
|
-
* TS, the watchdog's bash, the schedule-exit exit agent's inline JS, and the
|
|
9
|
-
* two supervisor installers) read and write these — a literal drifting in
|
|
10
|
-
* any one of them splits the protocol silently (three review rounds of path
|
|
11
|
-
* bugs came from exactly that). The bash sides are pinned by
|
|
12
|
-
* tests/state-files.spec.ts, which asserts every state-dir literal in the
|
|
13
|
-
* watchdog and both installers appears here, and that no script re-derives
|
|
14
|
-
* the directory from the home.
|
|
15
|
-
*/
|
|
16
|
-
/** Every state-directory file name, keyed by role. */
|
|
17
|
-
const STATE_FILES = {
|
|
18
|
-
/** The guard's credential/checkpoint/audit state (state.ts). */
|
|
19
|
-
guard: "self-restart-guard.json",
|
|
20
|
-
/** Watchdog-stamped last deployment-proven revision. */
|
|
21
|
-
lastGoodBoot: "last-good-boot.json",
|
|
22
|
-
/** schedule-exit → watchdog: an intentional restart, run the canary. */
|
|
23
|
-
restartRequested: "restart-requested.json",
|
|
24
|
-
/** The exit agent's restart outcome record (the report's source). */
|
|
25
|
-
lastRestart: "last-restart.json",
|
|
26
|
-
/** The SIGTERM snapshot of interrupted sessions. */
|
|
27
|
-
interruptedSessions: "interrupted-sessions.json",
|
|
28
|
-
/** The supervising watchdog's pidfile. */
|
|
29
|
-
watchdogPid: "watchdog.pid",
|
|
30
|
-
/** Cross-session mutual exclusion for the restart verb. */
|
|
31
|
-
restartLock: "restart.lock",
|
|
32
|
-
/** The detached restart driver's log. */
|
|
33
|
-
restartLog: "restart.log",
|
|
34
|
-
/** Marker: exit the watchdog without respawn. */
|
|
35
|
-
watchdogStop: "watchdog-stop",
|
|
36
|
-
/** Marker: the watchdog gave up; a crash page holds the port. */
|
|
37
|
-
watchdogGaveUp: "watchdog-gave-up",
|
|
38
|
-
/** The current boot attempt's captured output. */
|
|
39
|
-
bootAttemptLog: "boot-attempt.log",
|
|
40
|
-
/** The watchdog's own log. */
|
|
41
|
-
watchdogLog: "watchdog.log",
|
|
42
|
-
/** The watchdog's stderr, captured separately by the supervisor installers. */
|
|
43
|
-
watchdogStderrLog: "watchdog.stderr.log",
|
|
44
|
-
/** The exit agent's log. */
|
|
45
|
-
scheduleExitLog: "schedule-exit.log",
|
|
46
|
-
/** How the current instance was launched (recorded by the plugin at apply). */
|
|
47
|
-
instanceLaunch: "instance-launch.json",
|
|
48
|
-
/** The atomically selected full launch configuration (stable or in cutover). */
|
|
49
|
-
launchSpec: "launch-spec.json",
|
|
50
|
-
/** Redacted durable receipt for the latest launch-configuration cutover. */
|
|
51
|
-
launchCutover: "launch-cutover.json",
|
|
52
|
-
/** Legacy combined operator-control marker, retained for rolling upgrades. */
|
|
53
|
-
cutoverControl: "launch-cutover-control.json",
|
|
54
|
-
/** Operator → watchdog: abort according to the pre-approved recovery policy. */
|
|
55
|
-
cutoverAbort: "launch-cutover-abort.json",
|
|
56
|
-
/** Monotonic stronger operator action: explicitly restore the previous spec. */
|
|
57
|
-
cutoverRestorePrevious: "launch-cutover-restore-previous.json",
|
|
58
|
-
/** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
|
|
59
|
-
browserHandoffRequest: "browser-handoff-request.json",
|
|
60
|
-
/** Browser → watchdog acknowledgement for one proven final process. */
|
|
61
|
-
browserHandoffAck: "browser-handoff-ack.json",
|
|
62
|
-
/** Whether the restart-protocol skill registered at apply (and why not). */
|
|
63
|
-
skillRegistration: "skill-registration.json",
|
|
64
|
-
/** Directory: the healthy-boot snapshot of the profile composition inputs. */
|
|
65
|
-
lastGoodComposition: "last-good-composition",
|
|
66
|
-
/** Directory prefix: a failing composition backed up before rollback restores over it. */
|
|
67
|
-
compositionBackup: "composition-backup-"
|
|
68
|
-
};
|
|
69
|
-
/** Absolute path of a state file inside a state directory. */
|
|
70
|
-
function stateFile(stateDir, role) {
|
|
71
|
-
return join(stateDir, STATE_FILES[role]);
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* The last deployment-proven revision, stamped by the watchdog on every
|
|
75
|
-
* healthy boot, or undefined. Lives here rather than in the credential core:
|
|
76
|
-
* the stamp is written by the watchdog and proves the deployment composed and
|
|
77
|
-
* came up — a green credential only ever proves build+test passed.
|
|
78
|
-
* @param stateDir - state directory.
|
|
79
|
-
* @returns the stamped revision, or undefined.
|
|
80
|
-
*/
|
|
81
|
-
function lastGoodBootRevision(stateDir) {
|
|
82
|
-
try {
|
|
83
|
-
const stamp = JSON.parse(readFileSync(stateFile(stateDir, "lastGoodBoot"), "utf8"));
|
|
84
|
-
return typeof stamp.revision === "string" && stamp.revision !== "" ? stamp.revision : void 0;
|
|
85
|
-
} catch {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* Whether the pid named by this raw pid/lock-file content is alive. Empty
|
|
91
|
-
* content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
|
|
92
|
-
* process group (always succeeds), which once read as "alive" and refused
|
|
93
|
-
* every restart forever — the bug that had to be fixed in two copies of this
|
|
94
|
-
* logic before it was consolidated here.
|
|
95
|
-
*/
|
|
96
|
-
function pidAlive(raw) {
|
|
97
|
-
const pid = Number(raw);
|
|
98
|
-
if (raw === "" || !Number.isInteger(pid) || pid <= 0) return false;
|
|
99
|
-
try {
|
|
100
|
-
process.kill(pid, 0);
|
|
101
|
-
return true;
|
|
102
|
-
} catch {
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
/** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
|
|
107
|
-
function livePidIn(file) {
|
|
108
|
-
try {
|
|
109
|
-
const raw = readFileSync(file, "utf8").trim();
|
|
110
|
-
return pidAlive(raw) ? raw : null;
|
|
111
|
-
} catch {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
/** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
|
|
116
|
-
function liveWatchdogPid(stateDir) {
|
|
117
|
-
const raw = livePidIn(stateFile(stateDir, "watchdogPid"));
|
|
118
|
-
const pid = raw === null ? null : Number(raw);
|
|
119
|
-
appendTestLifecycleEvent("watchdog-liveness-probe", {
|
|
120
|
-
pid: pid ?? 0,
|
|
121
|
-
live: pid !== null
|
|
122
|
-
}, "parent-observer");
|
|
123
|
-
return pid;
|
|
124
|
-
}
|
|
125
|
-
//#endregion
|
|
126
|
-
//#region lib/types/state.js
|
|
127
|
-
/**
|
|
128
|
-
* Pure credential/checkpoint state core for the self-restart guard.
|
|
129
|
-
*
|
|
130
|
-
* The guard records one "green build" credential — bound to the git HEAD it
|
|
131
|
-
* was recorded on and to a freshness window — and answers `verify()` against
|
|
132
|
-
* the CURRENT head and wall clock. A later fully gated boot may promote that
|
|
133
|
-
* credential into a deployment proof whose exact runtime fingerprint can be
|
|
134
|
-
* reused by the same-launch restart path. Framework-free: the cordis plugin,
|
|
135
|
-
* the CLI, and the invariant companion all share this module.
|
|
136
|
-
*/
|
|
137
|
-
/** Absolute path of the state file inside a state directory. */
|
|
138
|
-
function stateFilePath(stateDir) {
|
|
139
|
-
return stateFile(stateDir, "guard");
|
|
140
|
-
}
|
|
141
|
-
/** A fresh, credential-less state. */
|
|
142
|
-
function emptyState() {
|
|
143
|
-
return { audit: [] };
|
|
144
|
-
}
|
|
145
|
-
function isCredential(value) {
|
|
146
|
-
if (typeof value !== "object" || value === null) return false;
|
|
147
|
-
const c = value;
|
|
148
|
-
return typeof c.scope === "string" && typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.command === "string";
|
|
149
|
-
}
|
|
150
|
-
function isCheckpoint(value) {
|
|
151
|
-
if (typeof value !== "object" || value === null) return false;
|
|
152
|
-
const c = value;
|
|
153
|
-
return typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.message === "string";
|
|
154
|
-
}
|
|
155
|
-
function isSha256(value) {
|
|
156
|
-
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
157
|
-
}
|
|
158
|
-
function isProvenDeployment(value) {
|
|
159
|
-
if (typeof value !== "object" || value === null) return false;
|
|
160
|
-
const proof = value;
|
|
161
|
-
const credential = proof.credential;
|
|
162
|
-
const fingerprint = proof.fingerprint;
|
|
163
|
-
return proof.version === 1 && typeof proof.provenAt === "number" && credential !== void 0 && typeof credential.revision === "string" && credential.revision !== "" && typeof credential.recordedAt === "number" && typeof credential.scope === "string" && isSha256(credential.commandSha256) && fingerprint?.version === 1 && typeof fingerprint.credentialRevision === "string" && fingerprint.credentialRevision !== "" && typeof fingerprint.harnessRevision === "string" && fingerprint.harnessRevision !== "" && isSha256(fingerprint.launchSpecSha256) && isSha256(fingerprint.profileSha256) && isSha256(fingerprint.hostRuntimeSha256) && isSha256(proof.fingerprintSha256);
|
|
164
|
-
}
|
|
165
|
-
function isAuditEntry(value) {
|
|
166
|
-
if (typeof value !== "object" || value === null) return false;
|
|
167
|
-
const e = value;
|
|
168
|
-
return (e.action === "record" || e.action === "clear" || e.action === "checkpoint" || e.action === "prove-deployment") && typeof e.ts === "number" && typeof e.detail === "string";
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
171
|
-
* Read the state file; an absent file is an empty state, a malformed one is a
|
|
172
|
-
* loud misconfiguration error (never silently ignored).
|
|
173
|
-
* @param stateDir - directory holding the state file.
|
|
174
|
-
* @returns the parsed state.
|
|
175
|
-
*/
|
|
176
|
-
function loadState(stateDir) {
|
|
177
|
-
try {
|
|
178
|
-
const raw = readFileSync(stateFilePath(stateDir), "utf8");
|
|
179
|
-
const parsed = JSON.parse(raw);
|
|
180
|
-
if (parsed === null || typeof parsed !== "object") return emptyState();
|
|
181
|
-
const state = { audit: Array.isArray(parsed.audit) ? parsed.audit.filter(isAuditEntry) : [] };
|
|
182
|
-
if (isCredential(parsed.credential)) state.credential = parsed.credential;
|
|
183
|
-
if (isProvenDeployment(parsed.provenDeployment)) state.provenDeployment = parsed.provenDeployment;
|
|
184
|
-
if (isCheckpoint(parsed.checkpoint)) state.checkpoint = parsed.checkpoint;
|
|
185
|
-
return state;
|
|
186
|
-
} catch (error) {
|
|
187
|
-
if (error?.code === "ENOENT") return emptyState();
|
|
188
|
-
throw new Error(`ankh-guard: unreadable state file ${stateFilePath(stateDir)}: ${String(error)}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
/** Persist a state (creates the directory as needed). */
|
|
192
|
-
function saveState(stateDir, state) {
|
|
193
|
-
mkdirSync(stateDir, { recursive: true });
|
|
194
|
-
const file = stateFilePath(stateDir);
|
|
195
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
196
|
-
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
197
|
-
renameSync(tmp, file);
|
|
198
|
-
}
|
|
199
|
-
function withAudit(state, entry) {
|
|
200
|
-
return {
|
|
201
|
-
...state,
|
|
202
|
-
audit: [...state.audit, entry].slice(-50)
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* Record a green-build credential bound to {@link RecordInput.revision}.
|
|
207
|
-
* @param stateDir - state directory.
|
|
208
|
-
* @param input - scope, the git HEAD, and the command that went green.
|
|
209
|
-
* @param now - epoch milliseconds (injected for deterministic tests).
|
|
210
|
-
* @returns the persisted state.
|
|
211
|
-
*/
|
|
212
|
-
function recordCredential(stateDir, input, now) {
|
|
213
|
-
const credential = {
|
|
214
|
-
...input,
|
|
215
|
-
recordedAt: now
|
|
216
|
-
};
|
|
217
|
-
const { provenDeployment: _oldProof, ...withoutOldProof } = withAudit(loadState(stateDir), {
|
|
218
|
-
action: "record",
|
|
219
|
-
ts: now,
|
|
220
|
-
detail: `${input.scope} @ ${input.revision}`
|
|
221
|
-
});
|
|
222
|
-
const updated = {
|
|
223
|
-
...withoutOldProof,
|
|
224
|
-
credential
|
|
225
|
-
};
|
|
226
|
-
saveState(stateDir, updated);
|
|
227
|
-
return updated;
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Drop the credential (checkpoint and audit survive).
|
|
231
|
-
* @param stateDir - state directory.
|
|
232
|
-
* @param now - epoch milliseconds (injected for deterministic tests).
|
|
233
|
-
* @returns the persisted state.
|
|
234
|
-
*/
|
|
235
|
-
function clearCredential(stateDir, now) {
|
|
236
|
-
const audited = withAudit(loadState(stateDir), {
|
|
237
|
-
action: "clear",
|
|
238
|
-
ts: now,
|
|
239
|
-
detail: "credential cleared"
|
|
240
|
-
});
|
|
241
|
-
const updated = { audit: audited.audit };
|
|
242
|
-
if (audited.checkpoint !== void 0) updated.checkpoint = audited.checkpoint;
|
|
243
|
-
saveState(stateDir, updated);
|
|
244
|
-
return updated;
|
|
245
|
-
}
|
|
246
|
-
/** Persist a deployment proof after the watchdog has completed its canary. */
|
|
247
|
-
function setProvenDeployment(stateDir, proof, now) {
|
|
248
|
-
const updated = {
|
|
249
|
-
...withAudit(loadState(stateDir), {
|
|
250
|
-
action: "prove-deployment",
|
|
251
|
-
ts: now,
|
|
252
|
-
detail: `${proof.credential.revision} ${proof.fingerprintSha256.slice(0, 16)}`
|
|
253
|
-
}),
|
|
254
|
-
provenDeployment: proof
|
|
255
|
-
};
|
|
256
|
-
saveState(stateDir, updated);
|
|
257
|
-
return updated;
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Persist a pre-batch checkpoint commit reference.
|
|
261
|
-
* @param stateDir - state directory.
|
|
262
|
-
* @param input - the checkpoint commit SHA and its message.
|
|
263
|
-
* @param now - epoch milliseconds (injected for deterministic tests).
|
|
264
|
-
* @returns the persisted state.
|
|
265
|
-
*/
|
|
266
|
-
function setCheckpoint(stateDir, input, now) {
|
|
267
|
-
const checkpoint = {
|
|
268
|
-
...input,
|
|
269
|
-
recordedAt: now
|
|
270
|
-
};
|
|
271
|
-
const updated = {
|
|
272
|
-
...withAudit(loadState(stateDir), {
|
|
273
|
-
action: "checkpoint",
|
|
274
|
-
ts: now,
|
|
275
|
-
detail: `${input.revision} ${input.message}`
|
|
276
|
-
}),
|
|
277
|
-
checkpoint
|
|
278
|
-
};
|
|
279
|
-
saveState(stateDir, updated);
|
|
280
|
-
return updated;
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* Answer the gate: a credential is valid only when present, recorded on the
|
|
284
|
-
* CURRENT revision, and younger than {@link maxAgeMinutes}.
|
|
285
|
-
* @param state - the loaded state.
|
|
286
|
-
* @param currentRevision - the git HEAD of the checkout, or null when unavailable.
|
|
287
|
-
* @param now - epoch milliseconds (injected for deterministic tests).
|
|
288
|
-
* @param maxAgeMinutes - freshness window.
|
|
289
|
-
* @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
|
|
290
|
-
* @returns ok plus a human reason either way.
|
|
291
|
-
*/
|
|
292
|
-
function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
|
|
293
|
-
const credential = state.credential;
|
|
294
|
-
if (credential === void 0) return {
|
|
295
|
-
ok: false,
|
|
296
|
-
reason: "no green-build credential recorded"
|
|
297
|
-
};
|
|
298
|
-
if (currentRevision === null) return {
|
|
299
|
-
ok: false,
|
|
300
|
-
reason: "current git HEAD unavailable (not inside a git repository?)"
|
|
301
|
-
};
|
|
302
|
-
if (!workingTreeClean) return {
|
|
303
|
-
ok: false,
|
|
304
|
-
reason: "working tree is dirty (staged, unstaged, or untracked changes exist) — commit or remove them, then rebuild and re-record"
|
|
305
|
-
};
|
|
306
|
-
if (credential.revision !== currentRevision) return {
|
|
307
|
-
ok: false,
|
|
308
|
-
reason: `green credential is bound to revision ${credential.revision}, but current HEAD is ${currentRevision} — the tree changed since it was recorded; rebuild and re-record`
|
|
309
|
-
};
|
|
310
|
-
const ageMs = now - credential.recordedAt;
|
|
311
|
-
const maxMs = maxAgeMinutes * 6e4;
|
|
312
|
-
if (ageMs > maxMs) return {
|
|
313
|
-
ok: false,
|
|
314
|
-
reason: `green credential is stale (${Math.round(ageMs / 6e4)} min old, limit ${maxAgeMinutes} min) — rebuild and re-record`
|
|
315
|
-
};
|
|
316
|
-
const leftMs = Math.max(0, maxMs - ageMs);
|
|
317
|
-
return {
|
|
318
|
-
ok: true,
|
|
319
|
-
reason: `green credential valid (${credential.scope} @ ${credential.revision}, ${Math.round(leftMs / 6e4)} min left)`
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
//#endregion
|
|
323
|
-
export { setProvenDeployment as a, lastGoodBootRevision as c, pidAlive as d, stateFile as f, setCheckpoint as i, livePidIn as l, loadState as n, stateFilePath as o, recordCredential as r, verifyCredential as s, clearCredential as t, liveWatchdogPid as u };
|
package/lib/types/invariant.d.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Package-owned invariant companion for `@khorsheed/dsh-ankh-guard`.
|
|
3
|
-
* @module @khorsheed/dsh-ankh-guard/invariant
|
|
4
|
-
*/
|
|
5
|
-
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
-
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants';
|
|
7
|
-
/** Cordis companion plugin name. */
|
|
8
|
-
export declare const name = "ankh-guard-invariant";
|
|
9
|
-
/** Service required before the companion can reserve package ownership. */
|
|
10
|
-
export declare const inject: string[];
|
|
11
|
-
/**
|
|
12
|
-
* Owned-data check: the package's state file, when present at the default
|
|
13
|
-
* location, must parse as a well-formed guard state. A malformed file would
|
|
14
|
-
* make the gate unreadable — the credential's revision/recordedAt shape is
|
|
15
|
-
* this package's own contract, so corruption fails loud at load instead of at
|
|
16
|
-
* the moment a restart needs the gate.
|
|
17
|
-
* @param _ctx - host context (unused; the check reads only the state file).
|
|
18
|
-
* @param fail - invariant failure reporter.
|
|
19
|
-
*/
|
|
20
|
-
export declare const install: InvariantInstaller;
|
|
21
|
-
/**
|
|
22
|
-
* Register this package's invariant companion.
|
|
23
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
24
|
-
* @returns The installed registration's disposer after setup succeeds.
|
|
25
|
-
*/
|
|
26
|
-
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
27
|
-
//# sourceMappingURL=invariant.d.ts.map
|
package/lib/types/invariant.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Package-owned invariant companion for `@khorsheed/dsh-ankh-guard`.
|
|
3
|
-
* @module @khorsheed/dsh-ankh-guard/invariant
|
|
4
|
-
*/
|
|
5
|
-
/* jscpd:ignore-start */
|
|
6
|
-
import { existsSync } from 'node:fs';
|
|
7
|
-
import { resolveStateDir } from "./defaults.js";
|
|
8
|
-
import { loadState, stateFilePath } from "./state.js";
|
|
9
|
-
const PACKAGE_NAME = '@khorsheed/dsh-ankh-guard';
|
|
10
|
-
/** Cordis companion plugin name. */
|
|
11
|
-
export const name = 'ankh-guard-invariant';
|
|
12
|
-
/** Service required before the companion can reserve package ownership. */
|
|
13
|
-
export const inject = ['invariants'];
|
|
14
|
-
/**
|
|
15
|
-
* Owned-data check: the package's state file, when present at the default
|
|
16
|
-
* location, must parse as a well-formed guard state. A malformed file would
|
|
17
|
-
* make the gate unreadable — the credential's revision/recordedAt shape is
|
|
18
|
-
* this package's own contract, so corruption fails loud at load instead of at
|
|
19
|
-
* the moment a restart needs the gate.
|
|
20
|
-
* @param _ctx - host context (unused; the check reads only the state file).
|
|
21
|
-
* @param fail - invariant failure reporter.
|
|
22
|
-
*/
|
|
23
|
-
export const install = (_ctx, fail) => {
|
|
24
|
-
const stateDir = resolveStateDir(undefined);
|
|
25
|
-
const file = stateFilePath(stateDir);
|
|
26
|
-
if (!existsSync(file))
|
|
27
|
-
return;
|
|
28
|
-
try {
|
|
29
|
-
loadState(stateDir);
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
fail(`state file ${file} is malformed: ${String(error)}`);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
/**
|
|
36
|
-
* Register this package's invariant companion.
|
|
37
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
38
|
-
* @returns The installed registration's disposer after setup succeeds.
|
|
39
|
-
*/
|
|
40
|
-
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
41
|
-
/* jscpd:ignore-end */
|