@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2383 -209
  6. package/lib/client.js +257 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +722 -38
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/processes-BjZgJjQr.js +344 -0
  12. package/lib/restart-context-D6nISh28.js +1245 -0
  13. package/lib/restart-context-DUyExi9O.js +1245 -0
  14. package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
  15. package/lib/state-CZMypGkB.js +323 -0
  16. package/lib/test-seam-DnvLWTeO.js +119 -0
  17. package/lib/test-seam-cli.js +24 -0
  18. package/lib/test-seam-dwvaKjRp.js +459 -0
  19. package/lib/test-seam.js +2 -0
  20. package/lib/types/browser-handoff.d.ts +55 -0
  21. package/lib/types/browser-handoff.js +489 -0
  22. package/lib/types/cli.d.ts +34 -4
  23. package/lib/types/cli.js +1487 -225
  24. package/lib/types/client/index.d.ts +15 -0
  25. package/lib/types/client/index.js +264 -0
  26. package/lib/types/deployment-proof.d.ts +24 -0
  27. package/lib/types/deployment-proof.js +314 -0
  28. package/lib/types/exit-agent.js +2 -0
  29. package/lib/types/git.d.ts +12 -3
  30. package/lib/types/git.js +69 -7
  31. package/lib/types/index.d.ts +66 -3
  32. package/lib/types/index.js +157 -39
  33. package/lib/types/launch-spec.d.ts +263 -0
  34. package/lib/types/launch-spec.js +823 -0
  35. package/lib/types/preflight-runner.d.ts +23 -12
  36. package/lib/types/preflight-runner.js +152 -57
  37. package/lib/types/processes.d.ts +38 -6
  38. package/lib/types/processes.js +236 -10
  39. package/lib/types/restart-context.d.ts +50 -0
  40. package/lib/types/restart-context.js +106 -0
  41. package/lib/types/restart-request.d.ts +32 -0
  42. package/lib/types/restart-request.js +128 -0
  43. package/lib/types/state-files.d.ts +30 -0
  44. package/lib/types/state-files.js +55 -0
  45. package/lib/types/state.d.ts +29 -2
  46. package/lib/types/state.js +52 -7
  47. package/lib/types/temp-artifact.d.ts +15 -0
  48. package/lib/types/temp-artifact.js +17 -0
  49. package/lib/types/test-seam-cli.d.ts +3 -0
  50. package/lib/types/test-seam-cli.js +27 -0
  51. package/lib/types/test-seam.d.ts +55 -0
  52. package/lib/types/test-seam.js +112 -0
  53. package/lib/types/transition.d.ts +118 -0
  54. package/lib/types/transition.js +717 -0
  55. package/package.json +29 -9
  56. package/scripts/dsh-watchdog.sh +1388 -80
  57. package/scripts/install-launchd.sh +43 -5
  58. package/scripts/install-systemd.sh +43 -5
  59. package/scripts/on-install.js +1 -1
  60. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  61. package/lib/processes-hCAmwma-.js +0 -127
  62. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -1,421 +0,0 @@
1
- import { c as stateFile } from "./state-Dhx9VG44.js";
2
- import { execFileSync } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
- //#region lib/types/git.js
5
- /**
6
- * Git helpers for the self-restart guard: the credential binds to the current
7
- * HEAD, checkpoints are real commits, and rollback is a hard reset. All calls
8
- * are synchronous child-process invocations scoped to the repo directory.
9
- */
10
- /**
11
- * The repository's current HEAD, or null when the directory is not inside a
12
- * git repository (or git itself is unavailable).
13
- * @param repoDir - repository directory.
14
- * @returns the full HEAD sha, or null.
15
- */
16
- function currentHead(repoDir) {
17
- try {
18
- const out = execFileSync("git", ["rev-parse", "HEAD"], {
19
- cwd: repoDir,
20
- encoding: "utf8",
21
- stdio: "pipe"
22
- }).trim();
23
- return out.length > 0 ? out : null;
24
- } catch {
25
- return null;
26
- }
27
- }
28
- /**
29
- * Commit the whole working tree as a checkpoint snapshot (empty commits
30
- * allowed — a clean tree still records a rollback point).
31
- * @param repoDir - repository directory.
32
- * @param message - checkpoint commit message.
33
- * @param artifactPattern - staged paths matching this are reported as
34
- * build-artifact-looking warnings (deployment-specific — see
35
- * SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
36
- * @returns the new HEAD sha, or a failure reason.
37
- */
38
- function commitCheckpoint(repoDir, message, artifactPattern) {
39
- try {
40
- execFileSync("git", ["add", "-A"], {
41
- cwd: repoDir,
42
- stdio: "pipe"
43
- });
44
- const staged = execFileSync("git", [
45
- "diff",
46
- "--cached",
47
- "--name-only"
48
- ], {
49
- cwd: repoDir,
50
- encoding: "utf8"
51
- });
52
- const artifacts = artifactPattern === void 0 ? [] : staged.split("\n").filter((file) => artifactPattern.test(file));
53
- execFileSync("git", [
54
- "commit",
55
- "--allow-empty",
56
- "-m",
57
- message
58
- ], {
59
- cwd: repoDir,
60
- stdio: "pipe"
61
- });
62
- const sha = currentHead(repoDir);
63
- if (sha === null) return {
64
- ok: false,
65
- error: "checkpoint commit succeeded but HEAD became unreadable"
66
- };
67
- return {
68
- ok: true,
69
- sha,
70
- artifacts
71
- };
72
- } catch (error) {
73
- return {
74
- ok: false,
75
- error: `git checkpoint failed: ${String(error)}`
76
- };
77
- }
78
- }
79
- /**
80
- * Roll the checkout back to a checkpoint commit WITHOUT losing work: the
81
- * discarded HEAD becomes a `guard-backup-*` branch, and uncommitted tracked
82
- * changes become a second `-wip` anchor commit (`git stash create` snapshots
83
- * the worktree without touching it; untracked files survive `reset --hard`
84
- * on their own). Every reset path — the watchdog, the CLI, the agent-facing
85
- * service — funnels through here, so recovery never depends on the reflog.
86
- * @param repoDir - repository directory.
87
- * @param sha - the checkpoint commit to reset to.
88
- * @returns success with the recovery anchor refs, or a failure reason.
89
- */
90
- function resetToCheckpoint(repoDir, sha) {
91
- const anchors = [];
92
- try {
93
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").replace(/\..*$/, "");
94
- const anchor = `guard-backup-${stamp.slice(0, 8)}-${stamp.slice(8)}-${Math.random().toString(36).slice(2, 6)}`;
95
- const head = currentHead(repoDir);
96
- if (head !== null && head !== sha) try {
97
- execFileSync("git", [
98
- "branch",
99
- anchor,
100
- "HEAD"
101
- ], {
102
- cwd: repoDir,
103
- stdio: "pipe"
104
- });
105
- anchors.push(anchor);
106
- } catch {}
107
- let wip = "";
108
- try {
109
- wip = execFileSync("git", ["stash", "create"], {
110
- cwd: repoDir,
111
- encoding: "utf8"
112
- }).trim();
113
- } catch {}
114
- if (wip !== "") try {
115
- execFileSync("git", [
116
- "branch",
117
- `${anchor}-wip`,
118
- wip
119
- ], {
120
- cwd: repoDir,
121
- stdio: "pipe"
122
- });
123
- anchors.push(`${anchor}-wip`);
124
- } catch {}
125
- execFileSync("git", [
126
- "reset",
127
- "--hard",
128
- sha
129
- ], {
130
- cwd: repoDir,
131
- stdio: "pipe"
132
- });
133
- return {
134
- ok: true,
135
- anchors
136
- };
137
- } catch (error) {
138
- return {
139
- ok: false,
140
- error: `git reset --hard ${sha} failed: ${String(error)}`,
141
- anchors
142
- };
143
- }
144
- }
145
- //#endregion
146
- //#region lib/types/restart-context.js
147
- /**
148
- * Restart-record context injection and interrupted-session continuity. After
149
- * a scheduled restart, the FULL report waits for the initiating session's
150
- * root agent, whenever it resumes (session restore is lazy, so no other
151
- * session is ever woken for reporting); a record without an initiator is
152
- * claimed by the first root agent created. Separately, a snapshot written at
153
- * SIGTERM time (`interrupted-sessions.json`) records which root sessions had
154
- * a live turn when the process stopped, so the next restart boot can resume
155
- * those sessions and queue a "continue" turn. Pure logic reads the durable
156
- * files; the plugin wires them into `agent/created` and `agent.followup`.
157
- */
158
- /** Absolute path of the restart record inside a state directory. */
159
- function restartRecordFile(stateDir) {
160
- return stateFile(stateDir, "lastRestart");
161
- }
162
- /** Absolute path of the interrupted-session snapshot inside a state directory. */
163
- function interruptedSnapshotFile(stateDir) {
164
- return stateFile(stateDir, "interruptedSessions");
165
- }
166
- /**
167
- * The pending restart record, or null when none is awaiting a report (absent,
168
- * unparseable, or settled). The record stays pending until the initiator
169
- * resumes or the next restart replaces it (a new exitAt) — those are the only
170
- * retirement paths.
171
- * @param stateDir - state directory.
172
- * @returns the record without `reportedAt`, or null.
173
- */
174
- function pendingRestartRecord(stateDir) {
175
- const file = restartRecordFile(stateDir);
176
- if (!existsSync(file)) return null;
177
- try {
178
- const record = JSON.parse(readFileSync(file, "utf8"));
179
- return record.reportedAt !== void 0 ? null : record;
180
- } catch {
181
- return null;
182
- }
183
- }
184
- /**
185
- * The model-visible restart report (Chinese product copy, factual).
186
- * @param record - the pending restart record.
187
- * @param canaryPending - whether the restart marker is still present (the
188
- * watchdog has not yet run/cleared the canary).
189
- * @returns the report text, or an empty string for a malformed record.
190
- */
191
- function restartContextText(record, canaryPending) {
192
- if (record.exitAt === void 0 && record.error === void 0) return "";
193
- const time = record.exitAt !== void 0 ? new Date(record.exitAt).toISOString() : "未知时间";
194
- if (record.unexpected === true) return `[ankh-guard] 服务最近发生过一次非计划退出(崩溃或被手动停止):${time},watchdog 已自动拉起实例。请向用户简要回报这次非计划重启。`;
195
- return `[ankh-guard] 服务最近重启过:${time},退出${record.error !== void 0 ? `失败(${record.error})` : "成功"},${canaryPending ? "金丝雀尚未完成" : "金丝雀已处理"}。请向用户简要回报本次重启结果。`;
196
- }
197
- /**
198
- * The model-visible "continue" prompt for a session whose turn was
199
- * interrupted by the restart (its log tail was closed with
200
- * `reason.kind === 'interrupted'` by crash-recovery repair).
201
- * @param exitAt - epoch milliseconds of the exit that interrupted the turn.
202
- * @returns the prompt text.
203
- */
204
- function continueInterruptedText(exitAt) {
205
- return `[ankh-guard] 服务于 ${new Date(exitAt).toISOString()} 重启,你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务;若任务已不再适用,简要说明原因后停止。`;
206
- }
207
- /**
208
- * The combined prompt for a session that is BOTH the restart's initiator and
209
- * an interrupted session: one turn continues the work and reports the
210
- * restart, instead of two near-duplicate turns.
211
- * @param record - the pending restart record.
212
- * @param canaryPending - whether the restart marker is still present.
213
- * @returns the prompt text, or an empty string for a malformed record.
214
- */
215
- function continueAndReportText(record, canaryPending) {
216
- if (record.exitAt === void 0 && record.error === void 0) return "";
217
- const time = record.exitAt !== void 0 ? new Date(record.exitAt).toISOString() : "未知时间";
218
- if (record.unexpected === true) return `[ankh-guard] 服务于 ${time} 发生非计划退出(崩溃或被手动停止),watchdog 已自动拉起实例。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报这次非计划重启;若任务已不再适用,简要说明原因后停止。`;
219
- return `[ankh-guard] 服务于 ${time} 重启,退出${record.error !== void 0 ? `失败(${record.error})` : "成功"},${canaryPending ? "金丝雀尚未完成" : "金丝雀已处理"}。你上次正在进行的回合被中断(日志已标记 interrupted)。请检查当前状态并继续未完成的任务,并向用户简要回报本次重启结果;若任务已不再适用,简要说明原因后停止。`;
220
- }
221
- /**
222
- * Acknowledge a restart record so it injects exactly once.
223
- * @param stateDir - state directory.
224
- * @param record - the record being reported.
225
- * @param now - epoch milliseconds of the acknowledgement.
226
- */
227
- function acknowledgeRestartRecord(stateDir, record, now) {
228
- try {
229
- atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
230
- ...record,
231
- reportedAt: now
232
- })}\n`);
233
- } catch {}
234
- }
235
- /**
236
- * Record an unplanned-exit recovery (the watchdog respawned the instance with
237
- * no restart marker), unless a record still awaits its report. Returns whether
238
- * the record was written — the caller (CLI verb, invoked by the watchdog) logs
239
- * either way, so the watchdog log must never claim a write that was skipped.
240
- * @param stateDir - state directory.
241
- * @param now - epoch milliseconds of the recovery.
242
- * @returns true when the record was written, false when a pending record was kept.
243
- */
244
- function writeUnexpectedExitRecord(stateDir, now) {
245
- if (pendingRestartRecord(stateDir) !== null) return false;
246
- mkdirSync(stateDir, { recursive: true });
247
- atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
248
- exitAt: now,
249
- unexpected: true
250
- })}\n`);
251
- return true;
252
- }
253
- /**
254
- * Record the restart verb's outcome for the report machinery (the restart
255
- * verb is otherwise invisible to it: it writes no marker and no record, so a
256
- * restart it drove would never be reported to any session). Mirrors the exit
257
- * agent's record semantics; a still-pending earlier record is replaced only
258
- * by a completed newer restart.
259
- * @param stateDir - state directory.
260
- * @param record - the outcome fields (exitAt/pid for a stop, error on failure).
261
- */
262
- function writeRestartOutcome(stateDir, record) {
263
- mkdirSync(stateDir, { recursive: true });
264
- atomicWrite(restartRecordFile(stateDir), `${JSON.stringify(record)}\n`);
265
- }
266
- /**
267
- * Record the watchdog's ADOPTION takeover — the first restart a deployment
268
- * ever sees: `supervise` handed the port to the watchdog, which stopped the
269
- * pre-existing owner and booted the supervised instance. The session that
270
- * established supervision promised the user a verification report; without
271
- * this record nothing wakes it after the bounce (the adoption writes no
272
- * restart marker and no outcome record). Never overwrites a record that still
273
- * awaits its report.
274
- * @param stateDir - state directory.
275
- * @param now - epoch milliseconds of the takeover boot.
276
- * @param initiator - the session that established supervision, when known.
277
- * @returns whether the record was written.
278
- */
279
- function writeAdoptionRecord(stateDir, now, initiator) {
280
- if (pendingRestartRecord(stateDir) !== null) return false;
281
- mkdirSync(stateDir, { recursive: true });
282
- atomicWrite(restartRecordFile(stateDir), `${JSON.stringify({
283
- exitAt: now,
284
- ...initiator !== void 0 && initiator !== "" ? { initiator } : {}
285
- })}\n`);
286
- return true;
287
- }
288
- /**
289
- * Persist the launch record (atomic). The instance-facing side of
290
- * {@link writeInstanceLaunch}: only an `instance`-sourced record may be
291
- * replaced by another — a `supervisor` record carries the FULL supervision
292
- * chain (watchdog, launch wrapper) and the inner process must not overwrite
293
- * it with its own bare argv.
294
- * @param stateDir - state directory.
295
- * @param launch - the launch facts.
296
- * @returns whether the record was written.
297
- */
298
- function writeInstanceLaunch(stateDir, launch) {
299
- if (readInstanceLaunch(stateDir)?.source === "supervisor") return false;
300
- mkdirSync(stateDir, { recursive: true });
301
- atomicWrite(stateFile(stateDir, "instanceLaunch"), `${JSON.stringify(launch)}\n`);
302
- return true;
303
- }
304
- /** POSIX single-quote one word for a shell command line. */
305
- function shellQuote(word) {
306
- return `'${word.replace(/'/g, "'\\''")}'`;
307
- }
308
- /**
309
- * Render the instance's launch as a shell command: cwd, DSH_* env, and the
310
- * FULL node invocation — execArgv included, because a tsx chain
311
- * (`node --import tsx …`) rendered without it becomes a bare `node bin.ts`
312
- * that cannot load TypeScript sources.
313
- */
314
- /**
315
- * Per-invocation transients, never launch configuration: the restart driver's
316
- * own marker, the calling session's identity, and the shell/web hand-off
317
- * vars. A recorded launch must not leak them into the next instance (a
318
- * restart driver reading DSH_ANKH_RESTART_DRIVER would misbehave; a stale
319
- * DSH_SESSION_ID misattributes reports).
320
- */
321
- const TRANSIENT_ENV_KEYS = /* @__PURE__ */ new Set([
322
- "DSH_ANKH_RESTART_DRIVER",
323
- "DSH_SESSION_ID",
324
- "DSH_SESSION_JSONL",
325
- "DSH_WEB_URL",
326
- "DSH_SHELL"
327
- ]);
328
- function buildLaunchCommand(execPath, execArgv, args, cwd, env) {
329
- const envPart = Object.entries(env).filter(([key]) => !TRANSIENT_ENV_KEYS.has(key)).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ");
330
- const argv = [
331
- execPath,
332
- ...execArgv,
333
- ...args
334
- ].map(shellQuote).join(" ");
335
- return `cd ${shellQuote(cwd)} && ${envPart !== "" ? `${envPart} ` : ""}${argv}`;
336
- }
337
- /** Replace any record unconditionally (the supervisor's own write path). */
338
- function writeInstanceLaunchAsSupervisor(stateDir, launch) {
339
- mkdirSync(stateDir, { recursive: true });
340
- atomicWrite(stateFile(stateDir, "instanceLaunch"), `${JSON.stringify(launch)}\n`);
341
- }
342
- /**
343
- * Read the launch record, or null when absent/unparseable (older deployments,
344
- * or the plugin never applied in this home).
345
- * @param stateDir - state directory.
346
- * @returns the record, or null.
347
- */
348
- function readInstanceLaunch(stateDir) {
349
- try {
350
- const launch = JSON.parse(readFileSync(stateFile(stateDir, "instanceLaunch"), "utf8"));
351
- return typeof launch.command === "string" ? launch : null;
352
- } catch {
353
- return null;
354
- }
355
- }
356
- /**
357
- * Read the shutdown snapshot, or null when absent/unparseable. Malformed
358
- * snapshots are dropped by the caller's delete-after-read, never retried.
359
- * @param stateDir - state directory.
360
- * @returns the snapshot, or null.
361
- */
362
- function readInterruptedSnapshot(stateDir) {
363
- const file = interruptedSnapshotFile(stateDir);
364
- if (!existsSync(file)) return null;
365
- try {
366
- const snapshot = JSON.parse(readFileSync(file, "utf8"));
367
- if (!Array.isArray(snapshot.resume) || !Array.isArray(snapshot.interrupted)) return null;
368
- return snapshot;
369
- } catch {
370
- return null;
371
- }
372
- }
373
- /**
374
- * Write the shutdown snapshot (synchronous — called from a signal handler).
375
- * The state directory is created on demand: a fresh deployment has no
376
- * `$DSH_HOME/state` yet, and a missing directory must not silently drop the
377
- * snapshot.
378
- * @param stateDir - state directory.
379
- * @param snapshot - the snapshot to persist.
380
- */
381
- function writeInterruptedSnapshot(stateDir, snapshot) {
382
- try {
383
- mkdirSync(stateDir, { recursive: true });
384
- atomicWrite(interruptedSnapshotFile(stateDir), `${JSON.stringify(snapshot)}\n`);
385
- } catch {}
386
- }
387
- /** Write a small durable file atomically (tmp + rename in the same directory). */
388
- function atomicWrite(file, content) {
389
- const tmp = `${file}.${process.pid}.tmp`;
390
- writeFileSync(tmp, content);
391
- renameSync(tmp, file);
392
- }
393
- /**
394
- * Persist the skill-registration outcome (atomic, best-effort). A migration
395
- * or repackaging that drops the skill is otherwise invisible until someone
396
- * notices the catalog entry missing — this record lets `check-env` surface it.
397
- * @param stateDir - state directory.
398
- * @param record - the outcome of this boot's registration attempt.
399
- */
400
- function writeSkillRegistration(stateDir, record) {
401
- try {
402
- mkdirSync(stateDir, { recursive: true });
403
- atomicWrite(stateFile(stateDir, "skillRegistration"), `${JSON.stringify(record)}\n`);
404
- } catch {}
405
- }
406
- /**
407
- * Read the skill-registration record, or null when absent/unparseable (the
408
- * plugin never applied with this state dir, or predates the record).
409
- * @param stateDir - state directory.
410
- * @returns the record, or null.
411
- */
412
- function readSkillRegistration(stateDir) {
413
- try {
414
- const record = JSON.parse(readFileSync(stateFile(stateDir, "skillRegistration"), "utf8"));
415
- return typeof record.registered === "boolean" && typeof record.at === "number" ? record : null;
416
- } catch {
417
- return null;
418
- }
419
- }
420
- //#endregion
421
- export { writeUnexpectedExitRecord as _, interruptedSnapshotFile as a, resetToCheckpoint as b, readInterruptedSnapshot as c, writeAdoptionRecord as d, writeInstanceLaunch as f, writeSkillRegistration as g, writeRestartOutcome as h, continueInterruptedText as i, readSkillRegistration as l, writeInterruptedSnapshot as m, buildLaunchCommand as n, pendingRestartRecord as o, writeInstanceLaunchAsSupervisor as p, continueAndReportText as r, readInstanceLaunch as s, acknowledgeRestartRecord as t, restartContextText as u, commitCheckpoint as v, currentHead as y };