@khorsheed/dsh-ankh-guard 0.3.1 → 0.4.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 +7 -0
- package/README.en.md +4 -4
- package/README.i18n.yaml +2 -2
- package/README.md +4 -4
- package/lib/cli.js +74 -27
- package/lib/preflight-runner.js +67 -9
- package/lib/types/cli.js +19 -5
- package/lib/types/preflight-runner.d.ts +47 -0
- package/lib/types/preflight-runner.js +114 -9
- package/lib/types/transition.d.ts +50 -10
- package/lib/types/transition.js +71 -22
- package/package.json +3 -3
- package/skills/dsh-self-restart-guard/SKILL.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.3.2(2026-09-27)
|
|
4
|
+
|
|
5
|
+
适配宿主 0.1.7-rc.2 线:verifiedHost 前移至 0.1.7-rc.2(3080 生产实证线随宿主基线切到 rc.2);rc.1→rc.2 对本包无破坏性变更(逐类清点见 [Agent Note](../../.agents/notes/implemented/architecture/2026-09-27-host-017-rc2-breaking-changes.md)),全量构建+测试双绿。
|
|
6
|
+
|
|
7
|
+
- **修复 tarball profile 上 preflight 误报 FAIL**:runner 把 compose 算出的 runtime resolution(0.1.7 的 `resolution` / 0.1.6 的 `generation`)算完即丢,boot prepare 从不挂载——tarball profile 的 node_modules 没有任何 `@deepseek-ai/*` 条目,原生解析全灭(实测 177 个条目 failed to import,真实启动完全干净)。现在 composePreflightPatches 保留并按两线键名返回 pluginPackagesConfig,prepare 按 runProfile 顺序挂载(profileContext → 启动环境 → PluginPackages → provideCmdline);提供 profileContext 的线追加 dry-run 覆写 `{ id: 'hmr', disabled: true }` 守住 no-HMR 契约。判定契约(0/1/3)与诊断强度不变
|
|
8
|
+
- **skill 防踩坑**:`dsh-self-restart-guard` 的取证步骤写明「证据命令作用域到改动所在仓库」——凭证绑定 harness 检出的 git HEAD;家族外改动(tarball 进 profile 的插件)由部署驱动器(`pnpm deploy:3080`)在自己的绿色门禁里记录。对 harness 全量套件手跑 `record --run` 会先清空既有有效凭证再撞上本机无关红(~11 分钟、561 个与本改动无关的失败),跑完门禁零证据
|
|
9
|
+
|
|
3
10
|
## 0.3.1(2026-09-26)
|
|
4
11
|
|
|
5
12
|
适配宿主 rc.1 线并实证 0.1.5/0.1.7 双线可用(0.1.5-rc.1 全量 boot 实证,2026-09-25;0.1.7-rc.1 为 3080 生产验证线)。
|
package/README.en.md
CHANGED
|
@@ -6,7 +6,7 @@ Let an agent change its own code and restart its own service — without taking
|
|
|
6
6
|
|
|
7
7
|
When the agent wants to restart after editing code, this plugin asks one question first: did the build and tests pass? Yes, go ahead. No, blocked — so broken code can't take the service, and the conversation running inside it, down with it.
|
|
8
8
|
|
|
9
|
-
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-
|
|
9
|
+
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/ankh-guard.JPG" width="640" alt="a guarded restart: the agent announces its verification plan beforehand, and the canary reactivates the session afterwards to keep verifying">
|
|
10
10
|
|
|
11
11
|
## How it works
|
|
12
12
|
|
|
@@ -79,7 +79,7 @@ Full commands: `verify`, `record`, `status`, `clear`, `checkpoint`, `reset`, `ca
|
|
|
79
79
|
|
|
80
80
|
### preflight: the composition gate
|
|
81
81
|
|
|
82
|
-
`preflight` deep-dry-runs the exact composition a restart would boot: it composes the profile's full patch stack through the same path as the real launcher (bundle layers, user layers, overlays), boots the **whole plugin tree** in a subprocess through the same engine — every plugin's apply runs, because apply is activation — with an overlay pinning the webserver port to 0 (OS-assigned, so it never collides with the live instance), checks every registered client bundle artifact exists, and disposes (registrations are effects, so dispose rolls the dry-run back). Exit codes are the contract:
|
|
82
|
+
`preflight` deep-dry-runs the exact composition a restart would boot: it composes the profile's full patch stack through the same path as the real launcher (bundle layers, user layers, overlays), boots the **whole plugin tree** in a subprocess through the same engine — every plugin's apply runs, because apply is activation — with an overlay pinning the webserver port to 0 (OS-assigned, so it never collides with the live instance), checks every registered client bundle artifact exists, and reads back the agent preset registry's `broken` diagnostic (preset rows mount on the registry's standing scopes, not the profile root, so **a clean boot does not imply a usable preset**: a broken preset shows 加载失败 in the picker and its sessions fail resume with `never started` — 3080 hit exactly that on 2026-09-28 with the dry-run green all the way), and disposes (registrations are effects, so dispose rolls the dry-run back). Exit codes are the contract:
|
|
83
83
|
|
|
84
84
|
- `0` — the composition boots clean.
|
|
85
85
|
- `1` — a composition verdict: the tree a restart would boot is broken; the output names the failing layer.
|
|
@@ -139,7 +139,7 @@ For a protected target, the watchdog accepts a launch URL only from the final pr
|
|
|
139
139
|
|
|
140
140
|
When a candidate cannot read a reconstructible projection or cache left by the previous host, `--transition-file` can submit a reviewed schema-v1 quarantine plan. A plan accepts only non-overlapping, symlink-free paths below `home` that exclude guard state, with an explicit `quarantine` operation; it contains no host-version or filename knowledge. Example: `{"schemaVersion":1,"home":"/absolute/dsh-home","operations":[{"kind":"quarantine","path":"storages/<reconstructible-cache>","expect":"present"}]}`. Each `expect` is `present` or `absent`; isolated preflight and live apply must observe that same state or refuse before previous stops or target starts. The plan must cover both old paths that need to leave before target starts and new output paths that must leave before previous can recover after a target failure; list the latter explicitly with `expect: "absent"` even when they do not exist at preparation. Do not use this mechanism for authoritative logs, credentials, or irreplaceable data. Formats that require content transformation need a separate reversible migration tool and review.
|
|
141
141
|
|
|
142
|
-
The guard first copies the live home's
|
|
142
|
+
The guard first copies the live home's boot inputs with copy-on-write preference — the allowlist is `profiles/`, the home-level `cordis.patch.yml`, `settings.yaml`, `.credentials.yaml`, and `.anonymous-user-id`; plugin data directories (sessions, state, local-agent sub-homes, tarballs, scratch, …) are excluded by default, so a newly installed plugin's data never silently grows the snapshot, and the list only moves when the host's boot starts reading a new home input (a miss fails the preflight loudly instead of slowing the copy down). It then rebuilds pnpm/Cordis links as relative links whose targets are snapshot-owned copies; legitimate internal dependency cycles remain intact. External targets enter a hash-named, deduplicated materialization area inside the snapshot. A linked `node_modules` target anchors at the package's own parent `node_modules` — the tightest scope that preserves Node's ancestor lookup — rather than the whole store root. A post-copy `realpath` audit requires every writable link target to remain under the snapshot root. Runtime entries without copyable semantics (sockets, FIFOs, and links to them) are skipped and counted; dangling or unresolvable links, any other special files, writable escapes, and read/copy failures make `reconfigure` fail closed before it runs the candidate, creates a cutover, or stops previous. It then applies the same quarantine and runs target composition preflight in that safe copy. If the copy cannot be prepared or the target does not boot, previous keeps serving and the live home stays unchanged. Only after the successor owns supervision and has stopped and revalidated the previous process tree does it apply the hash-bound durable plan with same-filesystem renames. If the target is rejected, the watchdog first stops its proven process, retains replacements it created at transitioned paths under `launch-transitions/<cutover>/rejected-target/`, restores the exact previous bytes and records the result, and only then permits previous to start. Any unproven step parks at `awaiting-user` instead of exposing previous to mixed state. After target success, the quarantined previous content remains in the cutover directory for operator disposition; it is never deleted automatically.
|
|
143
143
|
|
|
144
144
|
**The restart report reaches the model by itself — and waits for its owner.** After a scheduled restart (a pending `last-restart.json` record), the plugin queues the report as the next turn via `agent.followup` — the official wake-the-agent seam the schedule system uses for reminders — so the agent reports the restart result without any user message. Session restore after a restart is lazy (an agent is created only when the UI or an RPC touches the session), so the report goes ONLY to the session that scheduled the exit (`schedule-exit` records `$DSH_SESSION_ID` as the initiator), whenever it resumes — no other session is ever woken for reporting, and the record stays pending until its owner resumes or the next restart replaces it (new `exitAt`). A record without an initiator is claimed by the first root agent created. Only root agents, once (acknowledged on delivery). Config `reportRestartContext`: `followup` (default, autonomous), `step` (ride the first step of whatever turn comes next), or `off`.
|
|
145
145
|
|
|
@@ -198,7 +198,7 @@ None.
|
|
|
198
198
|
|
|
199
199
|
- npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): supported — 0.1.5-rc.1 full-line boot-verified (42 packages including capture, 2026-09-25) — with two designed degrades. The composition-preflight gate runs through the standalone `preflight-runner` (composing through the published `@deepseek-ai/dsh-app-boot` primitives with a drift tripwire, since 0.1.5-rc.1 still does not export `composeProfile`) wherever a dsh app layout resolves — `--harness-root`, the durable launch spec, `DSH_HARNESS`, or the default checkout. On a pure npm deployment with no harness checkout the gate reports a notice and proceeds instead. The original-tab bridge feature-probes the optional WebServer/connection authentication seams; hosts without token auth naturally take the existing-cookie path. Cold reads (the parked probe and the preset derivation) ride the 0.1.5 handle-based sessionPersistence (`open(id, 'read')` → `read` → `close`; the one-shot `inspect` is gone). Every other capability is intact on the npm line. minHost stays 0.1.5-rc.1 — older hosts stay on the previous release line.
|
|
200
200
|
- Historical verification: a live npm-host 0.1.1-rc.2 → 0.1.2-alpha.4 isolated cutover passed (transition preflight removed a v3 whole-unit projection cache with an old-schema record from a home copy, live apply quarantined the old file, and target completed Token URL → 303 → cookie 200, the ownership stability window, and canary at zero retries; the old file remained byte-exact in the cutover directory. An untransitioned control over the same home failed on the missing Alpha.4 record fields, demonstrating that acceptance covered the real schema break).
|
|
201
|
-
- source line (deepseek-harness master, fork or upstream): ✅ (verifiedHost: 0.1.7-rc.
|
|
201
|
+
- source line (deepseek-harness master, fork or upstream): ✅ (verifiedHost: 0.1.7-rc.2) — the gate runs through the standalone `preflight-runner` (resolves the published `@deepseek-ai/dsh-app-boot` etc. from the live checkout), so no fork patch is required.
|
|
202
202
|
- Dual-line evidence: the 0.1.5 boot passes end-to-end through three compat layers — the [preset-registry dual-name probe](../../.agents/notes/implemented/bug-fix/2026-09-25-preset-registry-dual-name-probe.md), [dual-shape typert codecs](../../.agents/notes/implemented/bug-fix/2026-09-25-typert-codec-dual-shape.md), and [typert faces carrying zod@4](../../.agents/notes/implemented/bug-fix/2026-09-25-typert-faces-carry-zod-v4.md).
|
|
203
203
|
|
|
204
204
|
**Version line mapping**: the first release after 0.2.0 supports host `0.1.5-rc.1` and later; hosts on `0.1.2-rc.1` stay on `0.2.0`, hosts on `0.1.0-rc.6` ~ `0.1.1-rc.2` stay on the 0.1.x release line (last release `0.1.1`).
|
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# last confirmed-consistent state. Both languages carry equal authority; after
|
|
3
3
|
# editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm run verify-translation-pairing --write packages/ankh-guard/README.en.md
|
|
5
|
-
packages/ankh-guard/README.en.md:
|
|
6
|
-
packages/ankh-guard/README.md:
|
|
5
|
+
packages/ankh-guard/README.en.md: 998372daf4877938983f5272038b236957dbd964
|
|
6
|
+
packages/ankh-guard/README.md: 6fc053dfb906a7875e5172d40b972d16b8578216
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
agent 改完代码想重启的时候,这个插件会先问一句:这次改动,构建和测试都过了吗?过了才放行,没过就拦下来——免得改坏的代码把整个服务、连同正在进行的对话一起带走。
|
|
8
8
|
|
|
9
|
-
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-
|
|
9
|
+
<img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/ankh-guard.JPG" width="640" alt="一次受守护的重启:重启前告知验证项,重启后金丝雀自动激活会话并注入上下文继续验证">
|
|
10
10
|
|
|
11
11
|
## 工作原理
|
|
12
12
|
|
|
@@ -79,7 +79,7 @@ dsh-ankh-guard reconfigure --start "NEW CMD" --repo "<credential repo>" \
|
|
|
79
79
|
|
|
80
80
|
### preflight: the composition gate
|
|
81
81
|
|
|
82
|
-
`preflight` 对重启将要 boot 的组合做完全一致的深度干跑:走与真实 launcher 相同的路径组装 profile 的全部 patch 层(bundle 层、用户层、overlay),在子进程里用同一个引擎 boot **整棵插件树**——每个插件的 apply 都真实执行,因为 apply 即激活——同时用 overlay 把 webserver 端口钉到 0(操作系统分配,绝不与在跑实例抢端口),检查每个已注册 client bundle
|
|
82
|
+
`preflight` 对重启将要 boot 的组合做完全一致的深度干跑:走与真实 launcher 相同的路径组装 profile 的全部 patch 层(bundle 层、用户层、overlay),在子进程里用同一个引擎 boot **整棵插件树**——每个插件的 apply 都真实执行,因为 apply 即激活——同时用 overlay 把 webserver 端口钉到 0(操作系统分配,绝不与在跑实例抢端口),检查每个已注册 client bundle 产物存在,并回读 agent preset 注册表的 `broken` 诊断(preset 行挂在注册表的 standing scope 上而不是 profile 根,**boot 干净不代表 preset 可用**:坏 preset 的选择器卡片显示「加载失败」、其会话 resume 报 `never started`——3080 在 2026-09-28 撞过一次,干跑一路全绿),然后 dispose(注册即 effect,dispose 即回滚这次干跑)。退出码即契约:
|
|
83
83
|
|
|
84
84
|
- `0`——组合干净通过。
|
|
85
85
|
- `1`——组合结论:重启将要 boot 的树是坏的;输出会指明坏在哪一层。
|
|
@@ -139,7 +139,7 @@ dsh-ankh-guard reconfigure \
|
|
|
139
139
|
|
|
140
140
|
candidate 无法读取旧宿主留下的可重建投影或缓存时,`--transition-file` 可以提交一份经过评审的 schema-v1 隔离计划。计划只接受 `home` 下互不重叠、没有符号链接且不包含 guard state 的相对路径,以及显式的 `quarantine` 操作;它不内置任何宿主版本或文件名知识。示例:`{"schemaVersion":1,"home":"/absolute/dsh-home","operations":[{"kind":"quarantine","path":"storages/<可重建缓存>","expect":"present"}]}`。每项 `expect` 必须是 `present` 或 `absent`,副本 preflight 与 live apply 都必须观察到相同状态,否则在停 previous 前或启动 target 前拒绝。计划既要覆盖 target 启动前必须移开的旧路径,也要覆盖 target 失败后 previous 启动前必须清走的新输出路径;后者即使准备时不存在也必须用 `expect: "absent"` 显式列出。权威日志、凭据或不可重建数据不得借此移出;需要内容转换的格式应使用独立、可逆且另行评审的迁移工具。
|
|
141
141
|
|
|
142
|
-
guard 先以 copy-on-write 优先方式复制 live home
|
|
142
|
+
guard 先以 copy-on-write 优先方式复制 live home 的启动输入——白名单为 `profiles/`、home 级 `cordis.patch.yml`、`settings.yaml`、`.credentials.yaml`、`.anonymous-user-id`;插件数据目录(sessions、state、local-agent 子 home、tarballs、scratch 等)默认不进复制,新插件的数据目录不会悄悄扩大快照,名单只在宿主启动读取范围变化时才需要更新(漏项会以预检 FAIL 显形,而不是慢慢变大)。再把 pnpm/Cordis 链接重建为只指向 snapshot 内副本的相对链接;合法的内部依赖循环保留。外部目标进入 snapshot 自己的哈希命名去重物化区,`node_modules` 目标锚定在包自身的父 `node_modules`(保留 Node 祖先查找语义的最小范围),而不是整个 store 根。复制后逐链接 `realpath` 审计,任何可写目标都必须仍在 snapshot 根内;无可复制语义的运行时条目(socket、FIFO 及指向它们的链接)跳过并计数;悬空或不可解析链接、其余特殊文件、可写逃逸及读取/复制失败都会让 `reconfigure` 在运行 candidate、创建 cutover 或停止 previous 前 fail closed。在安全副本中执行相同隔离后才运行 target composition preflight;副本无法准备或 target 无法 boot 时,previous 继续运行且 live home 不变。successor 取得监督所有权、停止并复核 previous 进程树以后,才按照哈希绑定的耐久计划用同文件系统 rename 隔离原路径。target 被拒绝时,watchdog 必须先停止其已证明的进程,再把它在同路径产生的替代内容保留到 `launch-transitions/<cutover>/rejected-target/`,恢复 previous 原字节并写入回执,最后才允许 previous 启动;任一步无法证明完成都会停在 `awaiting-user`,不会让旧宿主读取混合状态。target 成功后,旧内容仍保存在 cutover 目录,等待 operator 后续处置,不会自动删除。
|
|
143
143
|
|
|
144
144
|
**重启报告自动到达模型——并只等它的主人。** 计划重启后(存在未确认的 `last-restart.json` 记录),插件通过 `agent.followup` 把报告排入下一回合,agent 无需任何用户消息即可回报重启结果。重启后的会话恢复是 lazy 的(只有 UI 或 RPC 碰到某个会话,它的 agent 才会被创建),所以完整报告只发给发起重启的会话(`schedule-exit` 把 `$DSH_SESSION_ID` 记为 initiator),等它何时恢复何时送达——其他会话永远不会为了报告被唤醒;记录保持未确认,直到发起会话恢复或下一次重启替换它(新 `exitAt`)。没有 initiator 的记录由首个创建的根 agent 领走。仅根 agent、仅一次(送达即确认)。配置 `reportRestartContext`:`followup`(默认,自主)、`step`(骑在下一次回合的第一步上)、或 `off`。
|
|
145
145
|
|
|
@@ -197,7 +197,7 @@ dsh-ankh-guard restart \
|
|
|
197
197
|
|
|
198
198
|
- npm 发布线(`@deepseek-ai/dsh@0.1.5-rc.1`):支持——0.1.5-rc.1 全量 boot 实证通过(42 包含 capture,2026-09-25)——带两处设计内降级。composition-preflight 门禁通过独立的 `preflight-runner` 运行(0.1.5-rc.1 仍未导出 `composeProfile`,runner 改经已发布的 `@deepseek-ai/dsh-app-boot` 原语组装,带漂移绊线测试),只要能解析到 dsh app 布局——`--harness-root`、耐久 launch spec、`DSH_HARNESS` 或默认检出路径——就完整运行。没有 harness 检出的纯 npm 部署下门禁退化为提示后放行。原标签页桥会探测可选 WebServer/connection 认证 seam,不使用 token 认证的宿主自然走现有 Cookie 路径;冷读(停靠探测与 preset 推导)走 0.1.5 的 handle 制 sessionPersistence(`open(id, 'read')` → `read` → `close`,一次性 `inspect` 已移除)。其余能力在 npm 线上完整。minHost 保持 0.1.5-rc.1,旧宿主请停留在旧发布线。
|
|
199
199
|
- 历史验证:npm host 的 0.1.1-rc.2 → 0.1.2-alpha.4 隔离切换已通过(transition preflight 在 home 副本上移开带旧 schema record 的 v3 whole-unit projection cache,live apply 隔离旧文件,target 以零重试完成 Token URL → 303 → Cookie 200、ownership 稳定窗口与 canary;旧文件逐字节保留在 cutover 目录。相同 home 的无 transition 对照因缺少 Alpha.4 record 字段而拒绝,证明验收覆盖了真实 schema 断裂面)。
|
|
200
|
-
- 源码线(deepseek-harness master,fork 或上游):✅(verifiedHost: 0.1.7-rc.
|
|
200
|
+
- 源码线(deepseek-harness master,fork 或上游):✅(verifiedHost: 0.1.7-rc.2)——门禁通过独立的 `preflight-runner` 运行(从在线 checkout 解析已发布的 `@deepseek-ai/dsh-app-boot` 等),不再需要 fork 补丁。
|
|
201
201
|
- 双线证据:0.1.5 的 boot 经三层兼容修复端到端通过——[preset-registry 双名探测](../../.agents/notes/implemented/bug-fix/2026-09-25-preset-registry-dual-name-probe.md)、[typert codec 双形状](../../.agents/notes/implemented/bug-fix/2026-09-25-typert-codec-dual-shape.md)、[face 自带 zod@4](../../.agents/notes/implemented/bug-fix/2026-09-25-typert-faces-carry-zod-v4.md)。
|
|
202
202
|
|
|
203
203
|
**版本线对照**:0.2.0 之后的首个发布起支持宿主 `0.1.5-rc.1` 及以后;宿主 `0.1.2-rc.1` 请停留在 `0.2.0`,宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 请停留在 0.1.x 发布线(末版 `0.1.1`)。
|
package/lib/cli.js
CHANGED
|
@@ -805,12 +805,26 @@ function snapshotCopyError(source, error) {
|
|
|
805
805
|
return /* @__PURE__ */ new Error(`preflight snapshot could not safely copy ${source}: ${String(error)}`);
|
|
806
806
|
}
|
|
807
807
|
/**
|
|
808
|
-
* Top-level home entries
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
808
|
+
* Top-level home entries the preflight snapshot copies — the ALLOWLIST of
|
|
809
|
+
* inputs the launcher's boot actually reads: the profile trees, the home-level
|
|
810
|
+
* patch layer, settings, and the credential/identity stores. Everything else
|
|
811
|
+
* (plugin data: sessions, state, local-agent sub-homes, tarballs, scratch, …)
|
|
812
|
+
* is excluded BY DEFAULT, so a newly installed plugin's data directory can
|
|
813
|
+
* never silently join the copy — this list moves only when the HOST's boot
|
|
814
|
+
* starts reading a new home input, and a miss fails the dry-run loudly with
|
|
815
|
+
* the missing path rather than degrading into a slow copy. The denylist this
|
|
816
|
+
* replaced failed twice the other way: a 24 GB scratch tree expired the
|
|
817
|
+
* credential mid-cutover (canary failed, restored), and on 2026-09-27 the
|
|
818
|
+
* local-agent sub-home's absolute links dragged the host checkout's entire
|
|
819
|
+
* node_modules into a ~4 GB / 848 s prepare.
|
|
812
820
|
*/
|
|
813
|
-
const
|
|
821
|
+
const SNAPSHOT_INCLUDED_TOP_LEVEL = [
|
|
822
|
+
"profiles",
|
|
823
|
+
"settings.yaml",
|
|
824
|
+
"cordis.patch.yml",
|
|
825
|
+
".credentials.yaml",
|
|
826
|
+
".anonymous-user-id"
|
|
827
|
+
];
|
|
814
828
|
function canonicalSnapshotSource(source) {
|
|
815
829
|
try {
|
|
816
830
|
return realpathSync(source);
|
|
@@ -859,7 +873,7 @@ function copySnapshotNode(source, destination, context) {
|
|
|
859
873
|
});
|
|
860
874
|
try {
|
|
861
875
|
for (const name of readdirSync(canonical)) {
|
|
862
|
-
if (canonical === context.rootCanonical &&
|
|
876
|
+
if (canonical === context.rootCanonical && !context.includeTopLevel.has(name)) continue;
|
|
863
877
|
copySnapshotNode(join(canonical, name), join(destination, name), context);
|
|
864
878
|
}
|
|
865
879
|
} catch (error) {
|
|
@@ -872,18 +886,32 @@ function copySnapshotNode(source, destination, context) {
|
|
|
872
886
|
copyFileSync(canonical, destination, constants.COPYFILE_FICLONE);
|
|
873
887
|
chmodSync(destination, linkMetadata.mode & 4095);
|
|
874
888
|
utimesSync(destination, linkMetadata.atime, linkMetadata.mtime);
|
|
889
|
+
context.copiedFiles++;
|
|
890
|
+
context.copiedBytes += linkMetadata.size;
|
|
891
|
+
context.onProgress?.({
|
|
892
|
+
files: context.copiedFiles,
|
|
893
|
+
bytes: context.copiedBytes,
|
|
894
|
+
skippedRuntimeEntries: context.skippedRuntimeEntries
|
|
895
|
+
});
|
|
875
896
|
} catch (error) {
|
|
876
897
|
throw snapshotCopyError(source, error);
|
|
877
898
|
}
|
|
878
899
|
}
|
|
879
900
|
/**
|
|
880
901
|
* Preserve Node's ancestor node_modules lookup for an external package while
|
|
881
|
-
* avoiding one copy per package link.
|
|
882
|
-
*
|
|
902
|
+
* avoiding one copy per package link. The anchor is the package's OWN parent
|
|
903
|
+
* node_modules — the LAST node_modules segment in the resolved target: pnpm
|
|
904
|
+
* store links resolve to …/.pnpm/<name>@<version>/node_modules/<name>, where
|
|
905
|
+
* that parent already holds the package's dependency siblings, so the lookup
|
|
906
|
+
* survives at the tightest scope. Anchoring the FIRST segment instead dragged
|
|
907
|
+
* the whole multi-GB store root into the snapshot (observed 2026-09-27: 32
|
|
908
|
+
* profile links pulled in the host checkout's entire root node_modules).
|
|
909
|
+
* Other external targets are materialized individually and still deduplicated
|
|
910
|
+
* by canonical path.
|
|
883
911
|
*/
|
|
884
912
|
function externalMaterializationAnchor(target) {
|
|
885
913
|
const parsed = resolve(target).split(sep);
|
|
886
|
-
const nodeModulesIndex = parsed.
|
|
914
|
+
const nodeModulesIndex = parsed.lastIndexOf("node_modules");
|
|
887
915
|
if (nodeModulesIndex >= 0) return {
|
|
888
916
|
source: parsed.slice(0, nodeModulesIndex + 1).join(sep) || sep,
|
|
889
917
|
destination: "node_modules"
|
|
@@ -958,17 +986,21 @@ function finalizeSnapshotDirectories(context) {
|
|
|
958
986
|
}
|
|
959
987
|
}
|
|
960
988
|
/**
|
|
961
|
-
* Clone a live home while retaining a contained package-link
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
989
|
+
* Clone a live home's BOOT INPUTS while retaining a contained package-link
|
|
990
|
+
* graph. Only the allowlisted top-level entries are copied (see
|
|
991
|
+
* {@link SNAPSHOT_INCLUDED_TOP_LEVEL}) — plugin data directories are excluded
|
|
992
|
+
* by default, so the copy's size is bounded by what the composition's boot
|
|
993
|
+
* reads, not by whatever the home happens to hold. Internal links are rebuilt
|
|
994
|
+
* against copied nodes; external targets are deduplicated in a snapshot-owned
|
|
995
|
+
* materialization area. No retained link resolves outside the snapshot root,
|
|
996
|
+
* so writes through pnpm/Cordis links cannot reach live bytes. Runtime entries
|
|
997
|
+
* without copyable content (sockets, FIFOs — and links to them) are skipped
|
|
998
|
+
* and counted, never copied. Device nodes still fail closed.
|
|
968
999
|
* @param sourceHome - Live dsh home to read.
|
|
969
|
-
* @
|
|
1000
|
+
* @param options - Include-list override and progress callback.
|
|
1001
|
+
* @returns Isolated home, an idempotent cleanup callback, and copy statistics.
|
|
970
1002
|
*/
|
|
971
|
-
function createPreflightSnapshot(sourceHome) {
|
|
1003
|
+
function createPreflightSnapshot(sourceHome, options = {}) {
|
|
972
1004
|
const root = mkdtempSync(join(tmpdir(), "ankh-transition-preflight-"));
|
|
973
1005
|
const home = join(root, "home");
|
|
974
1006
|
try {
|
|
@@ -977,10 +1009,14 @@ function createPreflightSnapshot(sourceHome) {
|
|
|
977
1009
|
const context = {
|
|
978
1010
|
externalRoot: join(root, "materialized"),
|
|
979
1011
|
rootCanonical: source,
|
|
1012
|
+
includeTopLevel: new Set(options.includeTopLevel ?? SNAPSHOT_INCLUDED_TOP_LEVEL),
|
|
980
1013
|
destinations: /* @__PURE__ */ new Map(),
|
|
981
1014
|
pendingLinks: [],
|
|
982
1015
|
directories: [],
|
|
983
|
-
skippedRuntimeEntries: 0
|
|
1016
|
+
skippedRuntimeEntries: 0,
|
|
1017
|
+
copiedFiles: 0,
|
|
1018
|
+
copiedBytes: 0,
|
|
1019
|
+
...options.onProgress === void 0 ? {} : { onProgress: options.onProgress }
|
|
984
1020
|
};
|
|
985
1021
|
copySnapshotNode(source, home, context);
|
|
986
1022
|
resolveSnapshotLinks(context);
|
|
@@ -990,6 +1026,8 @@ function createPreflightSnapshot(sourceHome) {
|
|
|
990
1026
|
home,
|
|
991
1027
|
root,
|
|
992
1028
|
skippedRuntimeEntries: context.skippedRuntimeEntries,
|
|
1029
|
+
copiedFiles: context.copiedFiles,
|
|
1030
|
+
copiedBytes: context.copiedBytes,
|
|
993
1031
|
cleanup: () => {
|
|
994
1032
|
rmSync(root, {
|
|
995
1033
|
recursive: true,
|
|
@@ -1005,8 +1043,12 @@ function createPreflightSnapshot(sourceHome) {
|
|
|
1005
1043
|
throw error;
|
|
1006
1044
|
}
|
|
1007
1045
|
}
|
|
1008
|
-
function createTransitionPreflightSnapshot(plan) {
|
|
1009
|
-
const
|
|
1046
|
+
function createTransitionPreflightSnapshot(plan, options = {}) {
|
|
1047
|
+
const operationRoots = plan.operations.map((operation) => operation.path.split(sep)[0]);
|
|
1048
|
+
const snapshot = createPreflightSnapshot(plan.home, {
|
|
1049
|
+
...options,
|
|
1050
|
+
includeTopLevel: [.../* @__PURE__ */ new Set([...options.includeTopLevel ?? SNAPSHOT_INCLUDED_TOP_LEVEL, ...operationRoots])]
|
|
1051
|
+
});
|
|
1010
1052
|
try {
|
|
1011
1053
|
const stateDir = join(snapshot.root, "guard-state");
|
|
1012
1054
|
mkdirSync(stateDir, {
|
|
@@ -1017,10 +1059,7 @@ function createTransitionPreflightSnapshot(plan) {
|
|
|
1017
1059
|
...plan,
|
|
1018
1060
|
home: snapshot.home
|
|
1019
1061
|
}, snapshot.home, stateDir, "preflight"), snapshot.home, stateDir, "preflight");
|
|
1020
|
-
return
|
|
1021
|
-
home: snapshot.home,
|
|
1022
|
-
cleanup: snapshot.cleanup
|
|
1023
|
-
};
|
|
1062
|
+
return snapshot;
|
|
1024
1063
|
} catch (error) {
|
|
1025
1064
|
snapshot.cleanup();
|
|
1026
1065
|
throw error;
|
|
@@ -2785,12 +2824,20 @@ async function runCli(argv, io) {
|
|
|
2785
2824
|
const snapshotStartedAt = Date.now();
|
|
2786
2825
|
let snapshot;
|
|
2787
2826
|
try {
|
|
2788
|
-
|
|
2827
|
+
let lastProgressAt = 0;
|
|
2828
|
+
const onProgress = (progress) => {
|
|
2829
|
+
const now = Date.now();
|
|
2830
|
+
if (now - lastProgressAt < 2e3) return;
|
|
2831
|
+
lastProgressAt = now;
|
|
2832
|
+
io.stdout(`preflight snapshot: ${progress.files} files / ${Math.round(progress.bytes / 1024 / 1024)} MB copied…\n`);
|
|
2833
|
+
};
|
|
2834
|
+
snapshot = transitionPlan === void 0 ? createPreflightSnapshot(target.home, { onProgress }) : createTransitionPreflightSnapshot(transitionPlan, { onProgress });
|
|
2789
2835
|
} catch (error) {
|
|
2790
2836
|
return refuse("preflight-snapshot", `reconfigure refused: could not prepare an isolated${transitionPlan === void 0 ? "" : " transitioned"} home: ${String(error)}\n`);
|
|
2791
2837
|
}
|
|
2792
2838
|
const snapshotMs = Date.now() - snapshotStartedAt;
|
|
2793
|
-
|
|
2839
|
+
io.stdout(`isolated home snapshot ready: ${snapshot.copiedFiles} files / ${Math.round(snapshot.copiedBytes / 1024 / 1024)} MB in ${Math.round(snapshotMs / 1e3)}s\n`);
|
|
2840
|
+
if (snapshotMs > options.maxAgeMinutes * 6e4 / 2) io.stdout(`note: the isolated-home snapshot took ${Math.round(snapshotMs / 1e3)}s — over half the ${options.maxAgeMinutes}min credential window; re-record the credential immediately before reconfigure\n`);
|
|
2794
2841
|
try {
|
|
2795
2842
|
const timeout = options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
|
|
2796
2843
|
if (!await candidateProbeGate(target, timeout, io, snapshot.home)) return refuseQuiet("preflight", "reconfigure refused: the candidate probe failed (see stderr)");
|
package/lib/preflight-runner.js
CHANGED
|
@@ -26,6 +26,13 @@ import { pathToFileURL } from "node:url";
|
|
|
26
26
|
* activation — with the webserver port pinned to 0 (OS-assigned) so the
|
|
27
27
|
* dry-run never collides with the live instance;
|
|
28
28
|
* - every registered client bundle artifact exists on disk;
|
|
29
|
+
* - every registered agent preset is USABLE, not merely loaded: preset rows
|
|
30
|
+
* mount on the registry's standing scopes beside the profile tree, so a row
|
|
31
|
+
* whose module stopped resolving (a folded companion's retired package name,
|
|
32
|
+
* a base version predating its ./tool entry) never fails the boot itself —
|
|
33
|
+
* it fails every SESSION of that preset later (the picker shows 加载失败,
|
|
34
|
+
* resume answers "never started"; 3080, 2026-09-28). The audit reads the
|
|
35
|
+
* preset registry's own `broken` diagnostic back from the dry-run boot;
|
|
29
36
|
* - dispose rolls every effect back.
|
|
30
37
|
*
|
|
31
38
|
* Exit codes (the contract the guard consumes):
|
|
@@ -159,22 +166,34 @@ async function composePreflightPatches(profile, patchFiles, root, home, binding
|
|
|
159
166
|
if (hostLine === "rc") healProfilesModuleFallback(anchor, resolvedHome);
|
|
160
167
|
const composed = loadProfile(NAME, profile, anchor, resolvedHome, { userLayer: true });
|
|
161
168
|
writeFileSync(join(composed.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG);
|
|
169
|
+
let pluginPackagesConfig;
|
|
162
170
|
if (hostLine === "0.1.2") await healProfilesModuleFallback({
|
|
163
171
|
installAnchor: anchor,
|
|
164
172
|
profile: composed,
|
|
165
173
|
home: resolvedHome
|
|
166
174
|
});
|
|
167
|
-
else if (hostLine === "0.1.6") await createProfileResolutionGeneration({
|
|
175
|
+
else if (hostLine === "0.1.6") pluginPackagesConfig = { generation: await createProfileResolutionGeneration({
|
|
168
176
|
installAnchor: anchor,
|
|
169
177
|
profile: composed,
|
|
170
178
|
home: resolvedHome
|
|
171
|
-
});
|
|
172
|
-
else if (hostLine === "0.1.7") await createRuntimeResolution({
|
|
179
|
+
}) };
|
|
180
|
+
else if (hostLine === "0.1.7") pluginPackagesConfig = { resolution: await createRuntimeResolution({
|
|
173
181
|
installAnchor: anchor,
|
|
174
182
|
profile: composed
|
|
175
|
-
});
|
|
183
|
+
}) };
|
|
176
184
|
const homePatches = loadOptionalPatches(NAME, join(resolvedHome, HOME_PATCH_FILENAME)) ?? [];
|
|
177
185
|
const overlays = patchFiles.flatMap((file) => loadOverlayPatches(NAME, resolve(file)));
|
|
186
|
+
const profileContext = hostLine === "0.1.6" || hostLine === "0.1.7" ? {
|
|
187
|
+
name: profile,
|
|
188
|
+
dir: composed.dir,
|
|
189
|
+
patchPath: composed.patchPath,
|
|
190
|
+
installAnchor: anchor,
|
|
191
|
+
startedBundles: composed.layers.map((layer) => layer.packageName),
|
|
192
|
+
cwd: process.cwd(),
|
|
193
|
+
home: resolvedHome,
|
|
194
|
+
overlays,
|
|
195
|
+
telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED
|
|
196
|
+
} : void 0;
|
|
178
197
|
const bundlePatches = composed.layers.flatMap((layer) => layer.patches);
|
|
179
198
|
const patches = [
|
|
180
199
|
...bundlePatches,
|
|
@@ -208,6 +227,10 @@ async function composePreflightPatches(profile, patchFiles, root, home, binding
|
|
|
208
227
|
openBrowser: false
|
|
209
228
|
}
|
|
210
229
|
});
|
|
230
|
+
if (profileContext !== void 0 && rows.has("hmr")) composedOverlays.push({
|
|
231
|
+
id: "hmr",
|
|
232
|
+
disabled: true
|
|
233
|
+
});
|
|
211
234
|
if (rows.has("ankh-guard")) composedOverlays.push({
|
|
212
235
|
id: "ankh-guard",
|
|
213
236
|
config: {
|
|
@@ -224,7 +247,9 @@ async function composePreflightPatches(profile, patchFiles, root, home, binding
|
|
|
224
247
|
return {
|
|
225
248
|
patches,
|
|
226
249
|
rows,
|
|
227
|
-
profileDir: composed.dir
|
|
250
|
+
profileDir: composed.dir,
|
|
251
|
+
...profileContext === void 0 ? {} : { profileContext },
|
|
252
|
+
...pluginPackagesConfig === void 0 ? {} : { pluginPackagesConfig }
|
|
228
253
|
};
|
|
229
254
|
}
|
|
230
255
|
/**
|
|
@@ -275,6 +300,30 @@ function missingClientArtifacts(ctx) {
|
|
|
275
300
|
return missing;
|
|
276
301
|
}
|
|
277
302
|
/**
|
|
303
|
+
* The preset-roster half of the verdict. A profile can boot clean while one
|
|
304
|
+
* of its agent presets is BROKEN: preset rows mount on the registry's
|
|
305
|
+
* standing scopes, not on the profile root the dry-run boots, so a row whose
|
|
306
|
+
* module stopped resolving never fails the boot — it surfaces later as the
|
|
307
|
+
* preset picker's 加载失败 badge and `resume failed … never started` on every
|
|
308
|
+
* session of that preset (3080, 2026-09-28: the dev preset named
|
|
309
|
+
* `@khorsheed/dsh-worktrees/tool` while the installed worktrees predated the
|
|
310
|
+
* entry). The registry already computes this verdict: it activates every
|
|
311
|
+
* registered preset eagerly and records a mount failure as `broken`, and its
|
|
312
|
+
* `list()` re-audits mounted trees after the loader settles, so rows still
|
|
313
|
+
* waiting on a host service report their pending reason instead of passing
|
|
314
|
+
* silently. Fail the dry-run on any broken preset — the restart this gate
|
|
315
|
+
* protects would serve those broken sessions. A host whose registry face is
|
|
316
|
+
* absent or list-less degrades to no findings: the audit never invents one.
|
|
317
|
+
*/
|
|
318
|
+
async function brokenAgentPresets(ctx) {
|
|
319
|
+
const registry = ctx.get?.("agentPresets");
|
|
320
|
+
if (registry === void 0 || typeof registry.list !== "function") return [];
|
|
321
|
+
return (await registry.list()).flatMap((row) => row !== null && typeof row === "object" && typeof row.id === "string" && typeof row.broken === "string" ? [{
|
|
322
|
+
id: row.id,
|
|
323
|
+
broken: row.broken
|
|
324
|
+
}] : []);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
278
327
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
279
328
|
* the process streams. No HMR, no user-patch watchers, no signal wiring —
|
|
280
329
|
* preflight is one-shot.
|
|
@@ -302,6 +351,7 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
|
|
|
302
351
|
const loadLayeredEnv = appBoot.loadLayeredEnv;
|
|
303
352
|
const launchEnvironmentKey = launchEnvironment.DSH_LAUNCH_ENVIRONMENT_KEY;
|
|
304
353
|
const provideCmdline = cmdline.provideCmdline;
|
|
354
|
+
const PluginPackages = appBoot.PluginPackages;
|
|
305
355
|
let environment;
|
|
306
356
|
try {
|
|
307
357
|
environment = loadLayeredEnv(NAME);
|
|
@@ -322,8 +372,13 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
|
|
|
322
372
|
});
|
|
323
373
|
const rootConfig = join(composed.profileDir, PROFILE_ROOT_FILENAME);
|
|
324
374
|
const appReady = createAppReadyStub();
|
|
325
|
-
const ctx = await boot(NAME, rootConfig, structuredClone(patches), (hostCtx) => {
|
|
375
|
+
const ctx = await boot(NAME, rootConfig, structuredClone(patches), async (hostCtx) => {
|
|
376
|
+
if (composed.profileContext !== void 0) hostCtx.provide?.("profileContext", composed.profileContext);
|
|
326
377
|
hostCtx.provide?.(launchEnvironmentKey, environment);
|
|
378
|
+
if (composed.pluginPackagesConfig !== void 0) {
|
|
379
|
+
if (PluginPackages === void 0 || hostCtx.plugin === void 0) throw new Error("host line requires a PluginPackages mount but the loaded app-boot does not export PluginPackages");
|
|
380
|
+
await hostCtx.plugin(PluginPackages, composed.pluginPackagesConfig);
|
|
381
|
+
}
|
|
327
382
|
provideCmdline(hostCtx, {
|
|
328
383
|
args: [],
|
|
329
384
|
exit: () => {},
|
|
@@ -332,9 +387,12 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
|
|
|
332
387
|
});
|
|
333
388
|
appReady.commit();
|
|
334
389
|
const missing = missingClientArtifacts(ctx);
|
|
390
|
+
const brokenPresets = await brokenAgentPresets(ctx);
|
|
335
391
|
await ctx.fiber.dispose();
|
|
336
|
-
if (missing.length > 0) {
|
|
337
|
-
process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but client bundle artifacts are missing or unreadable:\n${missing.map((line) => ` - ${line}`).join("\n")}\nrun \`pnpm run build\` before launch\n`);
|
|
392
|
+
if (missing.length > 0 || brokenPresets.length > 0) {
|
|
393
|
+
if (missing.length > 0) process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but client bundle artifacts are missing or unreadable:\n${missing.map((line) => ` - ${line}`).join("\n")}\nrun \`pnpm run build\` before launch\n`);
|
|
394
|
+
if (brokenPresets.length > 0) process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but ${brokenPresets.length} agent preset(s) are broken — every session on them fails to resume (the preset picker shows 加载失败):\n${brokenPresets.map((preset) => ` - ${preset.id}: ${preset.broken.split("\n").join("\n ")}`).join("\n")}\nfix the named row (install the package it names, repoint a folded companion row to the core package's ./tool entry, or disable the row) or remove the preset, then re-run preflight
|
|
395
|
+
`);
|
|
338
396
|
return 1;
|
|
339
397
|
}
|
|
340
398
|
process.stdout.write(`preflight PASS: profile ${JSON.stringify(profile)} boots clean\n`);
|
|
@@ -401,4 +459,4 @@ if (isDirectInvocation(import.meta.url)) {
|
|
|
401
459
|
process.exitCode = await runPreflight(profile, patchFiles, resolveHarnessRoot(), binding);
|
|
402
460
|
}
|
|
403
461
|
//#endregion
|
|
404
|
-
export { composePreflightPatches, parsePreflightArgs, resolveHarnessRoot, runPreflight };
|
|
462
|
+
export { brokenAgentPresets, composePreflightPatches, parsePreflightArgs, resolveHarnessRoot, runPreflight };
|
package/lib/types/cli.js
CHANGED
|
@@ -1873,20 +1873,34 @@ export async function runCli(argv, io) {
|
|
|
1873
1873
|
const snapshotStartedAt = Date.now();
|
|
1874
1874
|
let snapshot;
|
|
1875
1875
|
try {
|
|
1876
|
+
// The copy runs synchronously before any stop; without progress output
|
|
1877
|
+
// a multi-GB prepare looked exactly like a hang (2026-09-27: 848 s of
|
|
1878
|
+
// silence dragging the host checkout's node_modules into the snapshot).
|
|
1879
|
+
let lastProgressAt = 0;
|
|
1880
|
+
const onProgress = (progress) => {
|
|
1881
|
+
const now = Date.now();
|
|
1882
|
+
if (now - lastProgressAt < 2000)
|
|
1883
|
+
return;
|
|
1884
|
+
lastProgressAt = now;
|
|
1885
|
+
io.stdout(`preflight snapshot: ${progress.files} files / ${Math.round(progress.bytes / 1024 / 1024)} MB copied…\n`);
|
|
1886
|
+
};
|
|
1876
1887
|
snapshot = transitionPlan === undefined
|
|
1877
|
-
? createPreflightSnapshot(target.home)
|
|
1878
|
-
: createTransitionPreflightSnapshot(transitionPlan);
|
|
1888
|
+
? createPreflightSnapshot(target.home, { onProgress })
|
|
1889
|
+
: createTransitionPreflightSnapshot(transitionPlan, { onProgress });
|
|
1879
1890
|
}
|
|
1880
1891
|
catch (error) {
|
|
1881
1892
|
return refuse('preflight-snapshot', `reconfigure refused: could not prepare an isolated${transitionPlan === undefined ? '' : ' transitioned'} home: ${String(error)}\n`);
|
|
1882
1893
|
}
|
|
1883
1894
|
// A large home copy eats the credential's freshness window: the post-boot
|
|
1884
1895
|
// canary revalidates the same credential, so a slow prepare can expire it
|
|
1885
|
-
// mid-cutover and force a restore (observed with a 24 GB scratch tree
|
|
1886
|
-
//
|
|
1896
|
+
// mid-cutover and force a restore (observed with a 24 GB scratch tree).
|
|
1897
|
+
// The snapshot copies only the composition's boot inputs
|
|
1898
|
+
// (SNAPSHOT_INCLUDED_TOP_LEVEL), so size tracks the host's boot surface —
|
|
1899
|
+
// warn when it still comes in slow.
|
|
1887
1900
|
const snapshotMs = Date.now() - snapshotStartedAt;
|
|
1901
|
+
io.stdout(`isolated home snapshot ready: ${snapshot.copiedFiles} files / ${Math.round(snapshot.copiedBytes / 1024 / 1024)} MB in ${Math.round(snapshotMs / 1000)}s\n`);
|
|
1888
1902
|
if (snapshotMs > options.maxAgeMinutes * 60_000 / 2) {
|
|
1889
|
-
io.stdout(`note: the isolated-home snapshot took ${Math.round(snapshotMs / 1000)}s — over half the ${options.maxAgeMinutes}min credential window; re-record the credential immediately before reconfigure
|
|
1903
|
+
io.stdout(`note: the isolated-home snapshot took ${Math.round(snapshotMs / 1000)}s — over half the ${options.maxAgeMinutes}min credential window; re-record the credential immediately before reconfigure\n`);
|
|
1890
1904
|
}
|
|
1891
1905
|
try {
|
|
1892
1906
|
const timeout = options.preflightTimeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* activation — with the webserver port pinned to 0 (OS-assigned) so the
|
|
20
20
|
* dry-run never collides with the live instance;
|
|
21
21
|
* - every registered client bundle artifact exists on disk;
|
|
22
|
+
* - every registered agent preset is USABLE, not merely loaded: preset rows
|
|
23
|
+
* mount on the registry's standing scopes beside the profile tree, so a row
|
|
24
|
+
* whose module stopped resolving (a folded companion's retired package name,
|
|
25
|
+
* a base version predating its ./tool entry) never fails the boot itself —
|
|
26
|
+
* it fails every SESSION of that preset later (the picker shows 加载失败,
|
|
27
|
+
* resume answers "never started"; 3080, 2026-09-28). The audit reads the
|
|
28
|
+
* preset registry's own `broken` diagnostic back from the dry-run boot;
|
|
22
29
|
* - dispose rolls every effect back.
|
|
23
30
|
*
|
|
24
31
|
* Exit codes (the contract the guard consumes):
|
|
@@ -52,6 +59,26 @@ export interface PreflightComposition {
|
|
|
52
59
|
}>;
|
|
53
60
|
/** The profile directory (the include root's anchor). */
|
|
54
61
|
profileDir: string;
|
|
62
|
+
/**
|
|
63
|
+
* The launcher-owned profileContext service value (0.1.6+ lines), built
|
|
64
|
+
* field-for-field as runProfile builds it. Undefined on the heal-based
|
|
65
|
+
* lines, whose launcher provides no such service. The boot prepare must
|
|
66
|
+
* provide it before the tree mounts: the 0.1.7 settings service injects
|
|
67
|
+
* profileContext, so without it every settings-dependent apply never runs.
|
|
68
|
+
*/
|
|
69
|
+
profileContext?: Record<string, unknown>;
|
|
70
|
+
/**
|
|
71
|
+
* The PluginPackages mount config the boot prepare must apply, mirroring
|
|
72
|
+
* the launcher: `{ generation }` on the 0.1.6 line, `{ resolution }` on the
|
|
73
|
+
* 0.1.7 line. Undefined on the heal-based lines (rc, 0.1.2), where the heal
|
|
74
|
+
* materializes real fallback links and native Node resolution carries the
|
|
75
|
+
* tree — the launcher mounts no PluginPackages there either. Without this
|
|
76
|
+
* mount the in-memory resolution is computed and discarded, so on a tarball
|
|
77
|
+
* profile (whose own node_modules holds no `@deepseek-ai/*` entries) every
|
|
78
|
+
* entry import fails natively while the real boot of the same profile is
|
|
79
|
+
* clean.
|
|
80
|
+
*/
|
|
81
|
+
pluginPackagesConfig?: Record<string, unknown>;
|
|
55
82
|
}
|
|
56
83
|
/**
|
|
57
84
|
* Compose one profile's full patch stack through the launcher's layering —
|
|
@@ -71,6 +98,26 @@ export interface PreflightComposition {
|
|
|
71
98
|
* @returns the patch stack and composed rows.
|
|
72
99
|
*/
|
|
73
100
|
export declare function composePreflightPatches(profile: string, patchFiles: readonly string[], root: string, home?: string, binding?: PreflightHostBinding): Promise<PreflightComposition>;
|
|
101
|
+
/**
|
|
102
|
+
* The preset-roster half of the verdict. A profile can boot clean while one
|
|
103
|
+
* of its agent presets is BROKEN: preset rows mount on the registry's
|
|
104
|
+
* standing scopes, not on the profile root the dry-run boots, so a row whose
|
|
105
|
+
* module stopped resolving never fails the boot — it surfaces later as the
|
|
106
|
+
* preset picker's 加载失败 badge and `resume failed … never started` on every
|
|
107
|
+
* session of that preset (3080, 2026-09-28: the dev preset named
|
|
108
|
+
* `@khorsheed/dsh-worktrees/tool` while the installed worktrees predated the
|
|
109
|
+
* entry). The registry already computes this verdict: it activates every
|
|
110
|
+
* registered preset eagerly and records a mount failure as `broken`, and its
|
|
111
|
+
* `list()` re-audits mounted trees after the loader settles, so rows still
|
|
112
|
+
* waiting on a host service report their pending reason instead of passing
|
|
113
|
+
* silently. Fail the dry-run on any broken preset — the restart this gate
|
|
114
|
+
* protects would serve those broken sessions. A host whose registry face is
|
|
115
|
+
* absent or list-less degrades to no findings: the audit never invents one.
|
|
116
|
+
*/
|
|
117
|
+
export declare function brokenAgentPresets(ctx: unknown): Promise<Array<{
|
|
118
|
+
id: string;
|
|
119
|
+
broken: string;
|
|
120
|
+
}>>;
|
|
74
121
|
/**
|
|
75
122
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
76
123
|
* the process streams. No HMR, no user-patch watchers, no signal wiring —
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* activation — with the webserver port pinned to 0 (OS-assigned) so the
|
|
20
20
|
* dry-run never collides with the live instance;
|
|
21
21
|
* - every registered client bundle artifact exists on disk;
|
|
22
|
+
* - every registered agent preset is USABLE, not merely loaded: preset rows
|
|
23
|
+
* mount on the registry's standing scopes beside the profile tree, so a row
|
|
24
|
+
* whose module stopped resolving (a folded companion's retired package name,
|
|
25
|
+
* a base version predating its ./tool entry) never fails the boot itself —
|
|
26
|
+
* it fails every SESSION of that preset later (the picker shows 加载失败,
|
|
27
|
+
* resume answers "never started"; 3080, 2026-09-28). The audit reads the
|
|
28
|
+
* preset registry's own `broken` diagnostic back from the dry-run boot;
|
|
22
29
|
* - dispose rolls every effect back.
|
|
23
30
|
*
|
|
24
31
|
* Exit codes (the contract the guard consumes):
|
|
@@ -200,6 +207,7 @@ export async function composePreflightPatches(profile, patchFiles, root, home, b
|
|
|
200
207
|
// the dry-run would otherwise fail on exactly the tree a first boot
|
|
201
208
|
// composes fine.
|
|
202
209
|
writeFileSync(join(composed.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG);
|
|
210
|
+
let pluginPackagesConfig;
|
|
203
211
|
if (hostLine === '0.1.2') {
|
|
204
212
|
await healProfilesModuleFallback({ installAnchor: anchor, profile: composed, home: resolvedHome });
|
|
205
213
|
}
|
|
@@ -208,19 +216,48 @@ export async function composePreflightPatches(profile, patchFiles, root, home, b
|
|
|
208
216
|
// compute the generation AFTER the profile load and root-config rewrite,
|
|
209
217
|
// materializing nothing. Awaited, so a resolution-graph failure rejects
|
|
210
218
|
// the compose instead of escaping as an unhandled rejection. The explicit
|
|
211
|
-
// home keeps the recorded profilesDir on the deployment under check.
|
|
212
|
-
|
|
219
|
+
// home keeps the recorded profilesDir on the deployment under check. The
|
|
220
|
+
// generation is kept: the launcher hands it to the boot's PluginPackages
|
|
221
|
+
// mount (`{ generation }`), and so must the dry-run — computing it and
|
|
222
|
+
// dropping it leaves profile-tree imports to native Node resolution,
|
|
223
|
+
// which finds nothing in a tarball profile's node_modules.
|
|
224
|
+
const generation = await createProfileResolutionGeneration({ installAnchor: anchor, profile: composed, home: resolvedHome });
|
|
225
|
+
pluginPackagesConfig = { generation };
|
|
213
226
|
}
|
|
214
227
|
else if (hostLine === '0.1.7') {
|
|
215
228
|
// The 0.1.7 launcher compose (apps/cli composeProfile): the resolution is
|
|
216
229
|
// computed in memory right after the profile load and root-config rewrite
|
|
217
|
-
// — the older lines' fallback projections are gone for good
|
|
230
|
+
// — the older lines' fallback projections are gone for good, so this
|
|
231
|
+
// interception is the ONLY way profile-tree imports resolve. Awaited, so
|
|
218
232
|
// a resolution-graph failure rejects the compose instead of escaping as
|
|
219
|
-
// an unhandled rejection.
|
|
220
|
-
|
|
233
|
+
// an unhandled rejection. The resolution is kept for the boot's
|
|
234
|
+
// PluginPackages mount (`{ resolution }`), exactly as the launcher's
|
|
235
|
+
// runProfile hands it over; discarding it is the tarball-profile false
|
|
236
|
+
// FAIL (every official entry reports "failed to import" on a profile
|
|
237
|
+
// whose real boot is clean).
|
|
238
|
+
const resolution = await createRuntimeResolution({ installAnchor: anchor, profile: composed });
|
|
239
|
+
pluginPackagesConfig = { resolution };
|
|
221
240
|
}
|
|
222
241
|
const homePatches = loadOptionalPatches(NAME, join(resolvedHome, HOME_PATCH_FILENAME)) ?? [];
|
|
223
242
|
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)));
|
|
243
|
+
// The launcher provides a data-only profileContext service before the tree
|
|
244
|
+
// mounts (since 0.1.6-alpha.2; the heal-based lines had no such service).
|
|
245
|
+
// The 0.1.7 settings service injects it, so a dry-run without it leaves
|
|
246
|
+
// `settings` — and everything injecting it — pending: applies the contract
|
|
247
|
+
// promises to exercise never run, on a profile the real boot runs clean.
|
|
248
|
+
const profileContext = hostLine === '0.1.6' || hostLine === '0.1.7'
|
|
249
|
+
? {
|
|
250
|
+
name: profile,
|
|
251
|
+
dir: composed.dir,
|
|
252
|
+
patchPath: composed.patchPath,
|
|
253
|
+
installAnchor: anchor,
|
|
254
|
+
startedBundles: composed.layers.map(layer => layer.packageName),
|
|
255
|
+
cwd: process.cwd(),
|
|
256
|
+
home: resolvedHome,
|
|
257
|
+
overlays,
|
|
258
|
+
telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,
|
|
259
|
+
}
|
|
260
|
+
: undefined;
|
|
224
261
|
const bundlePatches = composed.layers.flatMap(layer => layer.patches);
|
|
225
262
|
const patches = [...bundlePatches, ...composed.patches, ...homePatches, ...overlays];
|
|
226
263
|
const rows = new Map();
|
|
@@ -258,6 +295,15 @@ export async function composePreflightPatches(profile, patchFiles, root, home, b
|
|
|
258
295
|
},
|
|
259
296
|
});
|
|
260
297
|
}
|
|
298
|
+
if (profileContext !== undefined && rows.has('hmr')) {
|
|
299
|
+
// Providing profileContext satisfies the hmr row's disable expression
|
|
300
|
+
// (`!ctx.get('profileContext')`) on the runtime-resolution lines. A
|
|
301
|
+
// dry-run is one-shot — no HMR, no user-patch watchers: the boot's own
|
|
302
|
+
// tree write-back would queue a config refresh on hmr's operations queue,
|
|
303
|
+
// and dispose then awaits a queue that never drains (observed: preflight
|
|
304
|
+
// hung past boot and the process exited 13 on an unsettled await).
|
|
305
|
+
composedOverlays.push({ id: 'hmr', disabled: true });
|
|
306
|
+
}
|
|
261
307
|
if (rows.has('ankh-guard')) {
|
|
262
308
|
// The guard plugin writes state at apply (the instance-launch record,
|
|
263
309
|
// snapshots). A dry-run is NOT the real instance — isolate its state to a
|
|
@@ -279,7 +325,13 @@ export async function composePreflightPatches(profile, patchFiles, root, home, b
|
|
|
279
325
|
if (telemetryPatch !== undefined)
|
|
280
326
|
composedOverlays.push(telemetryPatch);
|
|
281
327
|
patches.push(...composedOverlays);
|
|
282
|
-
return {
|
|
328
|
+
return {
|
|
329
|
+
patches,
|
|
330
|
+
rows,
|
|
331
|
+
profileDir: composed.dir,
|
|
332
|
+
...(profileContext === undefined ? {} : { profileContext }),
|
|
333
|
+
...(pluginPackagesConfig === undefined ? {} : { pluginPackagesConfig }),
|
|
334
|
+
};
|
|
283
335
|
}
|
|
284
336
|
/**
|
|
285
337
|
* The launcher's readiness signal, mirrored: 0.1.2's runProfile provides an
|
|
@@ -332,6 +384,31 @@ function missingClientArtifacts(ctx) {
|
|
|
332
384
|
}
|
|
333
385
|
return missing;
|
|
334
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* The preset-roster half of the verdict. A profile can boot clean while one
|
|
389
|
+
* of its agent presets is BROKEN: preset rows mount on the registry's
|
|
390
|
+
* standing scopes, not on the profile root the dry-run boots, so a row whose
|
|
391
|
+
* module stopped resolving never fails the boot — it surfaces later as the
|
|
392
|
+
* preset picker's 加载失败 badge and `resume failed … never started` on every
|
|
393
|
+
* session of that preset (3080, 2026-09-28: the dev preset named
|
|
394
|
+
* `@khorsheed/dsh-worktrees/tool` while the installed worktrees predated the
|
|
395
|
+
* entry). The registry already computes this verdict: it activates every
|
|
396
|
+
* registered preset eagerly and records a mount failure as `broken`, and its
|
|
397
|
+
* `list()` re-audits mounted trees after the loader settles, so rows still
|
|
398
|
+
* waiting on a host service report their pending reason instead of passing
|
|
399
|
+
* silently. Fail the dry-run on any broken preset — the restart this gate
|
|
400
|
+
* protects would serve those broken sessions. A host whose registry face is
|
|
401
|
+
* absent or list-less degrades to no findings: the audit never invents one.
|
|
402
|
+
*/
|
|
403
|
+
export async function brokenAgentPresets(ctx) {
|
|
404
|
+
const registry = ctx.get?.('agentPresets');
|
|
405
|
+
if (registry === undefined || typeof registry.list !== 'function')
|
|
406
|
+
return [];
|
|
407
|
+
const rows = await registry.list();
|
|
408
|
+
return rows.flatMap(row => row !== null && typeof row === 'object' && typeof row.id === 'string' && typeof row.broken === 'string'
|
|
409
|
+
? [{ id: row.id, broken: row.broken }]
|
|
410
|
+
: []);
|
|
411
|
+
}
|
|
335
412
|
/**
|
|
336
413
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
337
414
|
* the process streams. No HMR, no user-patch watchers, no signal wiring —
|
|
@@ -363,6 +440,7 @@ export async function runPreflight(profile, patchFiles = [], root = resolveHarne
|
|
|
363
440
|
const loadLayeredEnv = appBoot.loadLayeredEnv;
|
|
364
441
|
const launchEnvironmentKey = launchEnvironment.DSH_LAUNCH_ENVIRONMENT_KEY;
|
|
365
442
|
const provideCmdline = cmdline.provideCmdline;
|
|
443
|
+
const PluginPackages = appBoot.PluginPackages;
|
|
366
444
|
let environment;
|
|
367
445
|
try {
|
|
368
446
|
environment = loadLayeredEnv(NAME);
|
|
@@ -389,8 +467,26 @@ export async function runPreflight(profile, patchFiles = [], root = resolveHarne
|
|
|
389
467
|
const appReady = createAppReadyStub();
|
|
390
468
|
// Cloned for the same insert-aliasing reason the launcher documents: boot
|
|
391
469
|
// application mutates rows by reference.
|
|
392
|
-
const ctx = await boot(NAME, rootConfig, structuredClone(patches), (hostCtx) => {
|
|
470
|
+
const ctx = await boot(NAME, rootConfig, structuredClone(patches), async (hostCtx) => {
|
|
471
|
+
// Mirror runProfile's prepare order: profileContext, launch environment,
|
|
472
|
+
// PluginPackages, cmdline — all before boot() mounts the root include.
|
|
473
|
+
if (composed.profileContext !== undefined)
|
|
474
|
+
hostCtx.provide?.('profileContext', composed.profileContext);
|
|
393
475
|
hostCtx.provide?.(launchEnvironmentKey, environment);
|
|
476
|
+
// On the runtime-resolution lines the composed resolution/generation
|
|
477
|
+
// must be mounted in-process through PluginPackages BEFORE the config
|
|
478
|
+
// tree mounts — boot() awaits prepare before the root include, so every
|
|
479
|
+
// entry import resolves through the interception. Skipping the mount is
|
|
480
|
+
// not a neutral shortcut: without it Node resolves profile-tree imports
|
|
481
|
+
// natively, a tarball profile's node_modules holds no official packages,
|
|
482
|
+
// and the dry-run reports a wall of "failed to import" on a tree the
|
|
483
|
+
// real launcher boots clean.
|
|
484
|
+
if (composed.pluginPackagesConfig !== undefined) {
|
|
485
|
+
if (PluginPackages === undefined || hostCtx.plugin === undefined) {
|
|
486
|
+
throw new Error('host line requires a PluginPackages mount but the loaded app-boot does not export PluginPackages');
|
|
487
|
+
}
|
|
488
|
+
await hostCtx.plugin(PluginPackages, composed.pluginPackagesConfig);
|
|
489
|
+
}
|
|
394
490
|
provideCmdline(hostCtx, { args: [], exit: () => { }, ready: appReady.service });
|
|
395
491
|
});
|
|
396
492
|
// The launcher commits readiness once boot and host setup settle; a
|
|
@@ -398,11 +494,20 @@ export async function runPreflight(profile, patchFiles = [], root = resolveHarne
|
|
|
398
494
|
// that point.
|
|
399
495
|
appReady.commit();
|
|
400
496
|
const missing = missingClientArtifacts(ctx);
|
|
497
|
+
// The registry settles pending rows against the finished loader tree, so
|
|
498
|
+
// the audit runs after boot completion and before dispose.
|
|
499
|
+
const brokenPresets = await brokenAgentPresets(ctx);
|
|
401
500
|
// A repeated dispose returns the settled single-shot result when boot
|
|
402
501
|
// already tore the tree down, so this is safe on every path.
|
|
403
502
|
await ctx.fiber.dispose();
|
|
404
|
-
if (missing.length > 0) {
|
|
405
|
-
|
|
503
|
+
if (missing.length > 0 || brokenPresets.length > 0) {
|
|
504
|
+
if (missing.length > 0) {
|
|
505
|
+
process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but client bundle artifacts are missing or unreadable:\n${missing.map(line => ` - ${line}`).join('\n')}\nrun \`pnpm run build\` before launch\n`);
|
|
506
|
+
}
|
|
507
|
+
if (brokenPresets.length > 0) {
|
|
508
|
+
process.stderr.write(`preflight FAIL: profile ${JSON.stringify(profile)} boots but ${brokenPresets.length} agent preset(s) are broken — every session on them fails to resume (the preset picker shows 加载失败):\n${brokenPresets.map(preset => ` - ${preset.id}: ${preset.broken.split('\n').join('\n ')}`).join('\n')}\n`
|
|
509
|
+
+ 'fix the named row (install the package it names, repoint a folded companion row to the core package\'s ./tool entry, or disable the row) or remove the preset, then re-run preflight\n');
|
|
510
|
+
}
|
|
406
511
|
return 1;
|
|
407
512
|
}
|
|
408
513
|
process.stdout.write(`preflight PASS: profile ${JSON.stringify(profile)} boots clean\n`);
|
|
@@ -94,24 +94,64 @@ export declare function rollbackTransition(reference: TransitionReference, expec
|
|
|
94
94
|
*/
|
|
95
95
|
export declare function readTransitionRecord(reference: TransitionReference, expectedHome: string, stateDir: string, cutoverId: string): TransitionRecord;
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
97
|
+
* Top-level home entries the preflight snapshot copies — the ALLOWLIST of
|
|
98
|
+
* inputs the launcher's boot actually reads: the profile trees, the home-level
|
|
99
|
+
* patch layer, settings, and the credential/identity stores. Everything else
|
|
100
|
+
* (plugin data: sessions, state, local-agent sub-homes, tarballs, scratch, …)
|
|
101
|
+
* is excluded BY DEFAULT, so a newly installed plugin's data directory can
|
|
102
|
+
* never silently join the copy — this list moves only when the HOST's boot
|
|
103
|
+
* starts reading a new home input, and a miss fails the dry-run loudly with
|
|
104
|
+
* the missing path rather than degrading into a slow copy. The denylist this
|
|
105
|
+
* replaced failed twice the other way: a 24 GB scratch tree expired the
|
|
106
|
+
* credential mid-cutover (canary failed, restored), and on 2026-09-27 the
|
|
107
|
+
* local-agent sub-home's absolute links dragged the host checkout's entire
|
|
108
|
+
* node_modules into a ~4 GB / 848 s prepare.
|
|
109
|
+
*/
|
|
110
|
+
export declare const SNAPSHOT_INCLUDED_TOP_LEVEL: readonly string[];
|
|
111
|
+
/** Copy progress, reported from the file branch of {@link copySnapshotNode}. */
|
|
112
|
+
export interface SnapshotProgress {
|
|
113
|
+
files: number;
|
|
114
|
+
bytes: number;
|
|
115
|
+
skippedRuntimeEntries: number;
|
|
116
|
+
}
|
|
117
|
+
export interface PreflightSnapshotOptions {
|
|
118
|
+
/**
|
|
119
|
+
* Top-level home entries to copy (default: {@link SNAPSHOT_INCLUDED_TOP_LEVEL}).
|
|
120
|
+
* `createTransitionPreflightSnapshot` unions its plan's operation roots in.
|
|
121
|
+
*/
|
|
122
|
+
includeTopLevel?: readonly string[];
|
|
123
|
+
/** Invoked as the copy advances; the caller throttles its own output. */
|
|
124
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Clone a live home's BOOT INPUTS while retaining a contained package-link
|
|
128
|
+
* graph. Only the allowlisted top-level entries are copied (see
|
|
129
|
+
* {@link SNAPSHOT_INCLUDED_TOP_LEVEL}) — plugin data directories are excluded
|
|
130
|
+
* by default, so the copy's size is bounded by what the composition's boot
|
|
131
|
+
* reads, not by whatever the home happens to hold. Internal links are rebuilt
|
|
132
|
+
* against copied nodes; external targets are deduplicated in a snapshot-owned
|
|
133
|
+
* materialization area. No retained link resolves outside the snapshot root,
|
|
134
|
+
* so writes through pnpm/Cordis links cannot reach live bytes. Runtime entries
|
|
135
|
+
* without copyable content (sockets, FIFOs — and links to them) are skipped
|
|
136
|
+
* and counted, never copied. Device nodes still fail closed.
|
|
104
137
|
* @param sourceHome - Live dsh home to read.
|
|
105
|
-
* @
|
|
138
|
+
* @param options - Include-list override and progress callback.
|
|
139
|
+
* @returns Isolated home, an idempotent cleanup callback, and copy statistics.
|
|
106
140
|
*/
|
|
107
|
-
export declare function createPreflightSnapshot(sourceHome: string): {
|
|
141
|
+
export declare function createPreflightSnapshot(sourceHome: string, options?: PreflightSnapshotOptions): {
|
|
108
142
|
home: string;
|
|
109
143
|
root: string;
|
|
110
144
|
skippedRuntimeEntries: number;
|
|
145
|
+
copiedFiles: number;
|
|
146
|
+
copiedBytes: number;
|
|
111
147
|
cleanup(): void;
|
|
112
148
|
};
|
|
113
|
-
export declare function createTransitionPreflightSnapshot(plan: TransitionPlan): {
|
|
149
|
+
export declare function createTransitionPreflightSnapshot(plan: TransitionPlan, options?: PreflightSnapshotOptions): {
|
|
114
150
|
home: string;
|
|
151
|
+
root: string;
|
|
152
|
+
skippedRuntimeEntries: number;
|
|
153
|
+
copiedFiles: number;
|
|
154
|
+
copiedBytes: number;
|
|
115
155
|
cleanup(): void;
|
|
116
156
|
};
|
|
117
157
|
export {};
|
package/lib/types/transition.js
CHANGED
|
@@ -499,12 +499,26 @@ function snapshotCopyError(source, error) {
|
|
|
499
499
|
return new Error(`preflight snapshot could not safely copy ${source}: ${String(error)}`);
|
|
500
500
|
}
|
|
501
501
|
/**
|
|
502
|
-
* Top-level home entries
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
502
|
+
* Top-level home entries the preflight snapshot copies — the ALLOWLIST of
|
|
503
|
+
* inputs the launcher's boot actually reads: the profile trees, the home-level
|
|
504
|
+
* patch layer, settings, and the credential/identity stores. Everything else
|
|
505
|
+
* (plugin data: sessions, state, local-agent sub-homes, tarballs, scratch, …)
|
|
506
|
+
* is excluded BY DEFAULT, so a newly installed plugin's data directory can
|
|
507
|
+
* never silently join the copy — this list moves only when the HOST's boot
|
|
508
|
+
* starts reading a new home input, and a miss fails the dry-run loudly with
|
|
509
|
+
* the missing path rather than degrading into a slow copy. The denylist this
|
|
510
|
+
* replaced failed twice the other way: a 24 GB scratch tree expired the
|
|
511
|
+
* credential mid-cutover (canary failed, restored), and on 2026-09-27 the
|
|
512
|
+
* local-agent sub-home's absolute links dragged the host checkout's entire
|
|
513
|
+
* node_modules into a ~4 GB / 848 s prepare.
|
|
506
514
|
*/
|
|
507
|
-
const
|
|
515
|
+
export const SNAPSHOT_INCLUDED_TOP_LEVEL = [
|
|
516
|
+
'profiles',
|
|
517
|
+
'settings.yaml',
|
|
518
|
+
'cordis.patch.yml',
|
|
519
|
+
'.credentials.yaml',
|
|
520
|
+
'.anonymous-user-id',
|
|
521
|
+
];
|
|
508
522
|
function canonicalSnapshotSource(source) {
|
|
509
523
|
try {
|
|
510
524
|
return realpathSync(source);
|
|
@@ -558,7 +572,7 @@ function copySnapshotNode(source, destination, context) {
|
|
|
558
572
|
});
|
|
559
573
|
try {
|
|
560
574
|
for (const name of readdirSync(canonical)) {
|
|
561
|
-
if (canonical === context.rootCanonical &&
|
|
575
|
+
if (canonical === context.rootCanonical && !context.includeTopLevel.has(name))
|
|
562
576
|
continue;
|
|
563
577
|
copySnapshotNode(join(canonical, name), join(destination, name), context);
|
|
564
578
|
}
|
|
@@ -574,6 +588,13 @@ function copySnapshotNode(source, destination, context) {
|
|
|
574
588
|
copyFileSync(canonical, destination, constants.COPYFILE_FICLONE);
|
|
575
589
|
chmodSync(destination, linkMetadata.mode & 0o7777);
|
|
576
590
|
utimesSync(destination, linkMetadata.atime, linkMetadata.mtime);
|
|
591
|
+
context.copiedFiles++;
|
|
592
|
+
context.copiedBytes += linkMetadata.size;
|
|
593
|
+
context.onProgress?.({
|
|
594
|
+
files: context.copiedFiles,
|
|
595
|
+
bytes: context.copiedBytes,
|
|
596
|
+
skippedRuntimeEntries: context.skippedRuntimeEntries,
|
|
597
|
+
});
|
|
577
598
|
}
|
|
578
599
|
catch (error) {
|
|
579
600
|
throw snapshotCopyError(source, error);
|
|
@@ -581,12 +602,19 @@ function copySnapshotNode(source, destination, context) {
|
|
|
581
602
|
}
|
|
582
603
|
/**
|
|
583
604
|
* Preserve Node's ancestor node_modules lookup for an external package while
|
|
584
|
-
* avoiding one copy per package link.
|
|
585
|
-
*
|
|
605
|
+
* avoiding one copy per package link. The anchor is the package's OWN parent
|
|
606
|
+
* node_modules — the LAST node_modules segment in the resolved target: pnpm
|
|
607
|
+
* store links resolve to …/.pnpm/<name>@<version>/node_modules/<name>, where
|
|
608
|
+
* that parent already holds the package's dependency siblings, so the lookup
|
|
609
|
+
* survives at the tightest scope. Anchoring the FIRST segment instead dragged
|
|
610
|
+
* the whole multi-GB store root into the snapshot (observed 2026-09-27: 32
|
|
611
|
+
* profile links pulled in the host checkout's entire root node_modules).
|
|
612
|
+
* Other external targets are materialized individually and still deduplicated
|
|
613
|
+
* by canonical path.
|
|
586
614
|
*/
|
|
587
615
|
function externalMaterializationAnchor(target) {
|
|
588
616
|
const parsed = resolve(target).split(sep);
|
|
589
|
-
const nodeModulesIndex = parsed.
|
|
617
|
+
const nodeModulesIndex = parsed.lastIndexOf('node_modules');
|
|
590
618
|
if (nodeModulesIndex >= 0) {
|
|
591
619
|
const prefix = parsed.slice(0, nodeModulesIndex + 1).join(sep) || sep;
|
|
592
620
|
return { source: prefix, destination: 'node_modules' };
|
|
@@ -665,17 +693,21 @@ function finalizeSnapshotDirectories(context) {
|
|
|
665
693
|
}
|
|
666
694
|
}
|
|
667
695
|
/**
|
|
668
|
-
* Clone a live home while retaining a contained package-link
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
696
|
+
* Clone a live home's BOOT INPUTS while retaining a contained package-link
|
|
697
|
+
* graph. Only the allowlisted top-level entries are copied (see
|
|
698
|
+
* {@link SNAPSHOT_INCLUDED_TOP_LEVEL}) — plugin data directories are excluded
|
|
699
|
+
* by default, so the copy's size is bounded by what the composition's boot
|
|
700
|
+
* reads, not by whatever the home happens to hold. Internal links are rebuilt
|
|
701
|
+
* against copied nodes; external targets are deduplicated in a snapshot-owned
|
|
702
|
+
* materialization area. No retained link resolves outside the snapshot root,
|
|
703
|
+
* so writes through pnpm/Cordis links cannot reach live bytes. Runtime entries
|
|
704
|
+
* without copyable content (sockets, FIFOs — and links to them) are skipped
|
|
705
|
+
* and counted, never copied. Device nodes still fail closed.
|
|
675
706
|
* @param sourceHome - Live dsh home to read.
|
|
676
|
-
* @
|
|
707
|
+
* @param options - Include-list override and progress callback.
|
|
708
|
+
* @returns Isolated home, an idempotent cleanup callback, and copy statistics.
|
|
677
709
|
*/
|
|
678
|
-
export function createPreflightSnapshot(sourceHome) {
|
|
710
|
+
export function createPreflightSnapshot(sourceHome, options = {}) {
|
|
679
711
|
const root = mkdtempSync(join(tmpdir(), 'ankh-transition-preflight-'));
|
|
680
712
|
const home = join(root, 'home');
|
|
681
713
|
try {
|
|
@@ -684,31 +716,48 @@ export function createPreflightSnapshot(sourceHome) {
|
|
|
684
716
|
const context = {
|
|
685
717
|
externalRoot: join(root, 'materialized'),
|
|
686
718
|
rootCanonical: source,
|
|
719
|
+
includeTopLevel: new Set(options.includeTopLevel ?? SNAPSHOT_INCLUDED_TOP_LEVEL),
|
|
687
720
|
destinations: new Map(),
|
|
688
721
|
pendingLinks: [],
|
|
689
722
|
directories: [],
|
|
690
723
|
skippedRuntimeEntries: 0,
|
|
724
|
+
copiedFiles: 0,
|
|
725
|
+
copiedBytes: 0,
|
|
726
|
+
...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
|
|
691
727
|
};
|
|
692
728
|
copySnapshotNode(source, home, context);
|
|
693
729
|
resolveSnapshotLinks(context);
|
|
694
730
|
assertSnapshotLinksContained(root, realpathSync(root));
|
|
695
731
|
finalizeSnapshotDirectories(context);
|
|
696
|
-
return {
|
|
732
|
+
return {
|
|
733
|
+
home,
|
|
734
|
+
root,
|
|
735
|
+
skippedRuntimeEntries: context.skippedRuntimeEntries,
|
|
736
|
+
copiedFiles: context.copiedFiles,
|
|
737
|
+
copiedBytes: context.copiedBytes,
|
|
738
|
+
cleanup: () => { rmSync(root, { recursive: true, force: true }); },
|
|
739
|
+
};
|
|
697
740
|
}
|
|
698
741
|
catch (error) {
|
|
699
742
|
rmSync(root, { recursive: true, force: true });
|
|
700
743
|
throw error;
|
|
701
744
|
}
|
|
702
745
|
}
|
|
703
|
-
export function createTransitionPreflightSnapshot(plan) {
|
|
704
|
-
|
|
746
|
+
export function createTransitionPreflightSnapshot(plan, options = {}) {
|
|
747
|
+
// A transition rehearses the plan's exact operation paths, so their
|
|
748
|
+
// top-level roots join the copy even when they are not boot inputs.
|
|
749
|
+
const operationRoots = plan.operations.map(operation => operation.path.split(sep)[0]);
|
|
750
|
+
const snapshot = createPreflightSnapshot(plan.home, {
|
|
751
|
+
...options,
|
|
752
|
+
includeTopLevel: [...new Set([...(options.includeTopLevel ?? SNAPSHOT_INCLUDED_TOP_LEVEL), ...operationRoots])],
|
|
753
|
+
});
|
|
705
754
|
try {
|
|
706
755
|
const stateDir = join(snapshot.root, 'guard-state');
|
|
707
756
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
708
757
|
const rebound = { ...plan, home: snapshot.home };
|
|
709
758
|
const reference = prepareTransition(rebound, snapshot.home, stateDir, 'preflight');
|
|
710
759
|
applyTransition(reference, snapshot.home, stateDir, 'preflight');
|
|
711
|
-
return
|
|
760
|
+
return snapshot;
|
|
712
761
|
}
|
|
713
762
|
catch (error) {
|
|
714
763
|
snapshot.cleanup();
|
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
|
+
"version": "0.4.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -90,8 +90,8 @@
|
|
|
90
90
|
},
|
|
91
91
|
"compat": {
|
|
92
92
|
"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.1.7-rc.
|
|
94
|
-
"verifiedHost": "0.1.7-rc.
|
|
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.1.7-rc.2 is the 3080 production-verified line; 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",
|
|
94
|
+
"verifiedHost": "0.1.7-rc.2"
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
}
|
|
@@ -52,6 +52,8 @@ $GUARD verify --repo <repo> --state-dir "$DSH_HOME/state"
|
|
|
52
52
|
|
|
53
53
|
For a classified pure same-launch restart, run only the `verify` line first. If it reports either a fresh green credential or `proven deployment valid`, continue without rerunning the expensive command. The reusable proof exists only after this guard version has observed a complete watchdog restart and canary; `last-good-boot.json` by itself, an older guard state, or a matching HEAD without the runtime fingerprint does not qualify. `schedule-exit` recomputes the fingerprint and pins the selected evidence SHA into the short-lived restart marker, and the successor watchdog revalidates it before accepting canary. A refusal is not bypassable: run the full evidence command above.
|
|
54
54
|
|
|
55
|
+
Scope the evidence command to the change being proved, and run it against the repo that change lives in. When the change lives OUTSIDE the credential repo — plugin packages shipped to a profile as tarballs, say — the credential repo's monorepo-wide suite proves nothing about the change and may carry unrelated red (a 35k-test suite with hundreds of pre-existing failures in experimental packages takes ~11 minutes to fail and records nothing); that is the deployment driver's job (e.g. `pnpm deploy:3080` records trust-command evidence from its own green gate — see below). Hand `record --run` is for changes inside the credential repo itself, scoped to the suites the change touches whenever that is honest. And because `record` clears the old credential and the reusable proof before running, a failed evidence command leaves the gate with zero valid evidence — `verify` first, record only what you must.
|
|
56
|
+
|
|
55
57
|
`--trust-command --command "..."` is reserved for an external orchestrator that already observed the command's real exit status (for example, the repository's deployment driver). It is not an agent shortcut.
|
|
56
58
|
|
|
57
59
|
4. **Composition preflight** — `restart`, `schedule-exit`, and `reconfigure` each run this gate internally exactly once and refuse before stopping the healthy host. For an ordinary same-launch restart, call the stop-capable verb directly; use the standalone verb only to diagnose an already-observed failure, never as a speculative duplicate immediately before that verb:
|