@khorsheed/dsh-ankh-guard 0.3.2 → 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/README.en.md +3 -3
- package/README.i18n.yaml +2 -2
- package/README.md +3 -3
- package/lib/cli.js +74 -27
- package/lib/preflight-runner.js +37 -3
- package/lib/types/cli.js +19 -5
- package/lib/types/preflight-runner.d.ts +27 -0
- package/lib/types/preflight-runner.js +43 -2
- package/lib/types/transition.d.ts +50 -10
- package/lib/types/transition.js +71 -22
- package/package.json +2 -2
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
|
|
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
|
|
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):
|
|
@@ -293,6 +300,30 @@ function missingClientArtifacts(ctx) {
|
|
|
293
300
|
return missing;
|
|
294
301
|
}
|
|
295
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
|
+
/**
|
|
296
327
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
297
328
|
* the process streams. No HMR, no user-patch watchers, no signal wiring —
|
|
298
329
|
* preflight is one-shot.
|
|
@@ -356,9 +387,12 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
|
|
|
356
387
|
});
|
|
357
388
|
appReady.commit();
|
|
358
389
|
const missing = missingClientArtifacts(ctx);
|
|
390
|
+
const brokenPresets = await brokenAgentPresets(ctx);
|
|
359
391
|
await ctx.fiber.dispose();
|
|
360
|
-
if (missing.length > 0) {
|
|
361
|
-
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
|
+
`);
|
|
362
396
|
return 1;
|
|
363
397
|
}
|
|
364
398
|
process.stdout.write(`preflight PASS: profile ${JSON.stringify(profile)} boots clean\n`);
|
|
@@ -425,4 +459,4 @@ if (isDirectInvocation(import.meta.url)) {
|
|
|
425
459
|
process.exitCode = await runPreflight(profile, patchFiles, resolveHarnessRoot(), binding);
|
|
426
460
|
}
|
|
427
461
|
//#endregion
|
|
428
|
-
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):
|
|
@@ -91,6 +98,26 @@ export interface PreflightComposition {
|
|
|
91
98
|
* @returns the patch stack and composed rows.
|
|
92
99
|
*/
|
|
93
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
|
+
}>>;
|
|
94
121
|
/**
|
|
95
122
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
96
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):
|
|
@@ -377,6 +384,31 @@ function missingClientArtifacts(ctx) {
|
|
|
377
384
|
}
|
|
378
385
|
return missing;
|
|
379
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
|
+
}
|
|
380
412
|
/**
|
|
381
413
|
* Boot the profile's full tree once, tear it down, and report the verdict on
|
|
382
414
|
* the process streams. No HMR, no user-patch watchers, no signal wiring —
|
|
@@ -462,11 +494,20 @@ export async function runPreflight(profile, patchFiles = [], root = resolveHarne
|
|
|
462
494
|
// that point.
|
|
463
495
|
appReady.commit();
|
|
464
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);
|
|
465
500
|
// A repeated dispose returns the settled single-shot result when boot
|
|
466
501
|
// already tore the tree down, so this is safe on every path.
|
|
467
502
|
await ctx.fiber.dispose();
|
|
468
|
-
if (missing.length > 0) {
|
|
469
|
-
|
|
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
|
+
}
|
|
470
511
|
return 1;
|
|
471
512
|
}
|
|
472
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,7 +90,7 @@
|
|
|
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.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",
|
|
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
94
|
"verifiedHost": "0.1.7-rc.2"
|
|
95
95
|
}
|
|
96
96
|
}
|