@sema-agent/server 7.114.0 → 7.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/MIGRATION.md +237 -2
  2. package/USAGE.md +39 -3
  3. package/deploy/sema-up/cluster-up.sh +13 -1
  4. package/deploy/sema-up/kube-up.sh +13 -1
  5. package/deploy/sema-up/preflight.sh +13 -1
  6. package/deploy/sema-up/sema-up.sh +13 -1
  7. package/deploy/sema-up/smoke.sh +13 -1
  8. package/dist/adoption/wire.d.ts +0 -3
  9. package/dist/approval-ask-audit-store.d.ts +25 -6
  10. package/dist/approval-ask-audit-store.js +6 -1
  11. package/dist/approval-card.d.ts +25 -24
  12. package/dist/approval-card.js +14 -0
  13. package/dist/approval-crash-convergence.d.ts +60 -0
  14. package/dist/approval-crash-convergence.js +75 -0
  15. package/dist/approval-deny-reasons.d.ts +13 -0
  16. package/dist/approval-deny-reasons.js +2 -0
  17. package/dist/approval.js +7 -3
  18. package/dist/boot/coordinators.js +0 -12
  19. package/dist/boot/execution-env.js +2 -1
  20. package/dist/boot/exit-boundary.d.ts +3 -2
  21. package/dist/boot/exit-boundary.js +3 -0
  22. package/dist/boot/resolve-spec.js +5 -1
  23. package/dist/boot/shutdown.d.ts +19 -0
  24. package/dist/boot/shutdown.js +12 -1
  25. package/dist/boot/stage-07-capability-layer.js +21 -15
  26. package/dist/boot/stores.js +4 -4
  27. package/dist/capabilities/composite-members.d.ts +1 -0
  28. package/dist/capabilities/composite-members.js +1 -0
  29. package/dist/capabilities/hands-lane.d.ts +0 -23
  30. package/dist/capabilities/hands-lane.js +3 -2
  31. package/dist/capabilities/send-user-file-tool.d.ts +10 -22
  32. package/dist/capabilities/send-user-file-tool.js +1 -8
  33. package/dist/capability-facts.d.ts +296 -0
  34. package/dist/capability-facts.js +354 -0
  35. package/dist/config-catalog.js +2 -1
  36. package/dist/config-center/apply-effective.js +11 -11
  37. package/dist/config-provider.d.ts +16 -0
  38. package/dist/config-provider.js +11 -2
  39. package/dist/config-types.d.ts +20 -1
  40. package/dist/config.d.ts +55 -29
  41. package/dist/config.js +48 -25
  42. package/dist/deployment-governance.d.ts +15 -4
  43. package/dist/deployment-governance.js +20 -2
  44. package/dist/fleet/fleet-bus.d.ts +1 -22
  45. package/dist/hooks/hook-runner.d.ts +29 -3
  46. package/dist/hooks/hook-runner.js +30 -17
  47. package/dist/http/admission.d.ts +6 -0
  48. package/dist/http/admission.js +29 -7
  49. package/dist/http/approval-sweeps.js +20 -0
  50. package/dist/http/checkpoint-ctx.d.ts +68 -0
  51. package/dist/http/checkpoint-ctx.js +36 -0
  52. package/dist/http/durable-run-leg.js +2 -1
  53. package/dist/http/principal-gate.d.ts +3 -0
  54. package/dist/http/principal-gate.js +3 -0
  55. package/dist/http/resume-legs.js +19 -1
  56. package/dist/http/retired-skill-manifest.d.ts +23 -0
  57. package/dist/http/retired-skill-manifest.js +22 -0
  58. package/dist/http/route-ctx.d.ts +7 -0
  59. package/dist/http/route-table.d.ts +3 -2
  60. package/dist/http/route-table.js +5 -5
  61. package/dist/http/routes/a2a-serve.d.ts +1 -3
  62. package/dist/http/routes/a2a-serve.js +14 -23
  63. package/dist/http/routes/admin-config-refresh.js +7 -2
  64. package/dist/http/routes/admin-drain.js +7 -2
  65. package/dist/http/routes/admin-memory-optout.d.ts +0 -2
  66. package/dist/http/routes/admin-memory-optout.js +20 -7
  67. package/dist/http/routes/adoption.js +19 -5
  68. package/dist/http/routes/agents-roster.js +4 -2
  69. package/dist/http/routes/approvals-assistant.js +41 -18
  70. package/dist/http/routes/background.d.ts +0 -2
  71. package/dist/http/routes/background.js +4 -5
  72. package/dist/http/routes/capabilities.d.ts +2 -1
  73. package/dist/http/routes/capabilities.js +63 -65
  74. package/dist/http/routes/config-catalog.d.ts +4 -5
  75. package/dist/http/routes/config-catalog.js +16 -2
  76. package/dist/http/routes/devices.js +17 -11
  77. package/dist/http/routes/diagnostics.d.ts +5 -0
  78. package/dist/http/routes/diagnostics.js +8 -1
  79. package/dist/http/routes/fleet.js +4 -2
  80. package/dist/http/routes/images.js +30 -3
  81. package/dist/http/routes/leader.js +7 -1
  82. package/dist/http/routes/mcp-reconnect.js +17 -10
  83. package/dist/http/routes/memory-bundle.js +20 -6
  84. package/dist/http/routes/memory-compliance.js +19 -5
  85. package/dist/http/routes/memory-consolidation.d.ts +9 -12
  86. package/dist/http/routes/memory-consolidation.js +17 -3
  87. package/dist/http/routes/memory-origin.js +19 -5
  88. package/dist/http/routes/notify-wake.js +5 -3
  89. package/dist/http/routes/observability.js +19 -5
  90. package/dist/http/routes/retention-ops.js +6 -1
  91. package/dist/http/routes/rules.js +49 -21
  92. package/dist/http/routes/run-memory-capture.js +5 -2
  93. package/dist/http/routes/runs.js +90 -60
  94. package/dist/http/routes/session-sync.js +64 -53
  95. package/dist/http/routes/sessions-list.js +8 -5
  96. package/dist/http/routes/sessions.js +40 -25
  97. package/dist/http/routes/shared-memory.js +5 -3
  98. package/dist/http/routes/tasks.js +5 -3
  99. package/dist/http/routes/trace-usage.js +18 -11
  100. package/dist/http/routes/workflows.js +36 -13
  101. package/dist/http/server.js +4 -1
  102. package/dist/http/wire-types.d.ts +6 -0
  103. package/dist/index.d.ts +1 -1
  104. package/dist/leader/wire.js +2 -1
  105. package/dist/local-data-root.d.ts +1 -1
  106. package/dist/mcp-content-origin.d.ts +0 -8
  107. package/dist/model-entry-refusal.d.ts +10 -2
  108. package/dist/model-entry-refusal.js +19 -3
  109. package/dist/namespaced-rule-name.d.ts +54 -0
  110. package/dist/namespaced-rule-name.js +33 -0
  111. package/dist/observability/corrupt-read-seat.d.ts +4 -3
  112. package/dist/observability/corrupt-read-seat.js +4 -3
  113. package/dist/observability/fail-open.d.ts +10 -9
  114. package/dist/observability/fail-open.js +5 -1
  115. package/dist/observability/permission-rule-events.d.ts +3 -0
  116. package/dist/observability/permission-rule-events.js +4 -0
  117. package/dist/permission-rule-list.d.ts +81 -0
  118. package/dist/permission-rule-list.js +17 -0
  119. package/dist/plugins/file-run-store.d.ts +7 -1
  120. package/dist/plugins/file-run-store.js +20 -7
  121. package/dist/plugins/memory-run-store.js +2 -1
  122. package/dist/plugins/one-time-migrations.d.ts +4 -2
  123. package/dist/plugins/one-time-migrations.js +26 -3
  124. package/dist/plugins/pg-session-storage.js +1 -1
  125. package/dist/plugins/run-store-sql.js +4 -4
  126. package/dist/plugins/session-policy-store-sql.js +4 -2
  127. package/dist/plugins/store-backend.d.ts +6 -1
  128. package/dist/plugins/store-backend.js +2 -1
  129. package/dist/plugins/store-contracts.d.ts +27 -8
  130. package/dist/plugins/store-contracts.js +12 -3
  131. package/dist/plugins/tidb-session-store.js +1 -1
  132. package/dist/request-key-closure.js +1 -1
  133. package/dist/run-local.d.ts +37 -3
  134. package/dist/run-local.js +52 -6
  135. package/dist/runs.d.ts +0 -58
  136. package/dist/runs.js +15 -20
  137. package/dist/runtime-governance.d.ts +21 -1
  138. package/dist/runtime-governance.js +6 -1
  139. package/dist/security.js +2 -2
  140. package/dist/session-policy-wire.d.ts +40 -59
  141. package/dist/session-policy-wire.js +60 -32
  142. package/dist/spec-fields.d.ts +32 -0
  143. package/dist/spec-fields.js +31 -16
  144. package/dist/standing-answer-grant.d.ts +8 -0
  145. package/dist/standing-answer-grant.js +5 -0
  146. package/dist/task-settings.d.ts +17 -6
  147. package/dist/task-settings.js +16 -30
  148. package/dist/tool-approval.d.ts +32 -4
  149. package/dist/tool-approval.js +31 -8
  150. package/dist/trace/core-keyset-guard.d.ts +13 -8
  151. package/dist/trace/project.js +9 -0
  152. package/dist/trace/self-minted-wire.d.ts +42 -0
  153. package/dist/trace/self-minted-wire.js +13 -0
  154. package/dist/user-input-ledger.d.ts +8 -5
  155. package/package.json +6 -4
  156. package/scripts/drain-check-local.mjs +528 -0
  157. package/scripts/drain-check.mysql.sql +67 -0
  158. package/scripts/drain-check.pg.sql +71 -0
  159. package/scripts/retired-mode-seed-drain-local.mjs +0 -317
@@ -13,15 +13,18 @@
13
13
  * 「本服务以回执向调用方承诺已交给引擎、而消费只发生在进程内存里的用户输入」,**且承载它的那只 `TaskStream` 活在本服务的一条 run 腿里**(S-966)——
14
14
  * 后半句是收敛判词成立的前提:证据面是那条 run 行(下文「收敛判据」)。行的 `taskId` = 回执上的 run、`sessionId` = 那条 run 的会话(人看的那一只),
15
15
  * run 里是哪一只流(run 自己的 / 同步子代的)**不是**账本的事实:告知按 run 并帧(`steer` = 条数),读面行同形。今天只有 steer 一族(`kind:"steer"` =
16
- * core 的车道词:引擎把这些输入记在 steer 车道),三个入口同一个引擎动词 `TaskStream.steer`:
16
+ * 受理它的引擎动词 `TaskStream.steer`,三档 `now` / `next` / `later` 同一个动词同一族),三个入口同一个引擎动词:
17
17
  * · `POST /v1/runs/:id/steer` 的 live 腿(200 applied);
18
18
  * · `POST /v1/runs/:id/interrupt` 的带 text 形(202 queued-immediate,档位 now;设计稿把它划在病面外,车 SW 亲核它进的是同一只进程内队列 ⇒ 同类同修);
19
19
  * · `POST /v1/runs/:id/subagents/:target/steer`(200 applied,S-966)—— 投进**同步子代**自己的流(core `createSteerHandle` → `stream.steer`)。子代由父腿
20
20
  * await,父腿收场时 core 撤掉在飞的复活(`subagentRetain.disposeAll`),所以它活在父 run 的腿里;腿 = 子代句柄,`handle.settled` 兑现 ⇒ 结清。
21
21
  * 规则的反面(不是例外表):workflow agent steer(`POST /v1/workflows/:id/agents/:label/steer`,200 applied)不在本账 —— workflow 是会话锚定的后台件
22
22
  * (core `WorkflowTaskHandle.sessionScoped`),活得比发起它的 run 腿长,没有一条 run 行约束它,而 workflow run 行上没有持有者列 ⇒ 判不出写者死没死(另票);
23
- * 子代 resume(`…/subagents/:target/resume`)不往在飞的队列里投,起的是一条新的子代腿;follow-up 在本仓**没有**进程内受理口(`src/http/routes` 零
24
- * follow-up 入口;steer 路由不下传 `priority`,core 缺省 `next` = steer 车道)⇒ 闭集一词,不预留第二词。
23
+ * 子代 resume(`…/subagents/:target/resume`)不往在飞的队列里投,起的是一条新的子代腿;follow-up 在本仓**没有**独立的受理口(`src/http/routes` 零
24
+ * follow-up 入口)。steer 的 `later` 档在引擎里走 follow-up 车道(引擎在腿收场时按车道分账:没投出去的 `later` 记进 `task.user_followup_undrained`),
25
+ * 但它仍是一次 `TaskStream.steer` 受理、消费帧同为 `human_input{delivery:"applied"}`(两车道同一只消费口)⇒ 同进本账,消费即结清;崩溃告知走 core 唯一的
26
+ * 服务层臂 `task.user_steer_undrained{cause:"crashed"}`(core 对该臂的定义按动词:服务层受理过的 steer)。follow-up 一族在 core 里没有 `cause` 键、没有
27
+ * 服务层臂 ⇒ 本账无处按车道归档,要按车道归档须 core 先给那一臂(上游待答)⇒ 闭集一词,不预留第二词。
25
28
  *
26
29
  * ## 五形行(append-only;行序 = 语义序;File 孪生的盘上形 = 下面的 {@link UserInputLedgerRecord},SQL 孪生 = 一行一受理 + CAS 列 + 删行,同义)
27
30
  * · `accepted` —— 受理点(`routes/runs.ts` 的 live 腿,**调 `TaskStream.steer` 之前**、回 200 之前)写;写不进 ⇒ 503
@@ -50,8 +53,8 @@
50
53
  * ## 收敛判据(一条:**受理它的进程已经不在,而承载它的腿没有正常收场**)—— 两车道同一只判词({@link judgeOpen},S-969 L8 合一)
51
54
  * 证据面只有一个:承载它的 **run 行**(run 店;行上的 `instanceId` = 当前持有者,createRun 铸、markResuming 接管)。
52
55
  * · 行在、持有者是写者、仍 `running` ⇒ 腿没收场;写者在不在由车道答(下一段)—— 不在 ⇒ `crashed`,可能在 ⇒ `live`(不动,等 reaper)。
53
- * · 行在、持有者是写者、`failed` 且落款是 run 店的**死亡判决**({@link isHolderDeathVerdict}:`reapStale` 的 `RUN_STALLED_ERROR` /
54
- * File 车道孤儿回收与 claim 接管的 `RUN_ORPHANED_ERROR`)⇒ 写者死在腿收场之前 ⇒ `crashed`。判决落盘之后才收(codex r1-F1 / r3-F1:
56
+ * · 行在、持有者是写者、`failed` 且行上是 run 店的**死亡判决码**({@link isHolderDeathVerdict}:`reapStale` 的 `run.stalled` /
57
+ * File 车道孤儿回收与 claim 接管的 `run.orphaned`;7.116.0 起按码判,句子只给人读)⇒ 写者死在腿收场之前 ⇒ `crashed`。判决落盘之后才收(codex r1-F1 / r3-F1:
55
58
  * 读到的旧心跳不算 —— 读后写者可能恢复;收敛与 reap 的先后因此不承重)。维护 tick 里收敛排在 reapStale **之后**(同一条腿串行)。
56
59
  * · 其余 —— 行正常翻面(完成 / 失败 / 取消)、已 park(suspended / needs_review:引擎把未消费的 steer 迁进检查点,续跑重投)、易手(续跑被别的
57
60
  * 进程接走)⇒ 腿正常收场了,只是没人替它落 settle ⇒ 补落 `settled{leg_end}`(判词 `leg_over`,见 {@link OpenVerdict}),不告知。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.114.0",
3
+ "version": "7.116.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -28,7 +28,9 @@
28
28
  "!dist/run-local-linux-x64",
29
29
  "!dist/run-local-darwin-arm64",
30
30
  "skills",
31
- "scripts/retired-mode-seed-drain-local.mjs",
31
+ "scripts/drain-check-local.mjs",
32
+ "scripts/drain-check.mysql.sql",
33
+ "scripts/drain-check.pg.sql",
32
34
  "deploy/sema-up",
33
35
  "docs/DEVICE-EXECUTOR-PROTOCOL.md",
34
36
  "USAGE.md",
@@ -62,8 +64,8 @@
62
64
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
63
65
  },
64
66
  "dependencies": {
65
- "@sema-agent/core": "7.39.2",
66
- "@sema-agent/settings-schema": "7.0.1",
67
+ "@sema-agent/core": "7.41.2",
68
+ "@sema-agent/settings-schema": "8.0.0",
67
69
  "e2b": "^2.28.0",
68
70
  "libsodium-wrappers": "^0.8.4",
69
71
  "mysql2": "^3.22.4",
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 换钉前的**排空检查 —— `DB_BACKEND=local`(File 店)车道**,只读。随 npm 包出(`scripts/drain-check-local.mjs`)。
4
+ * 一次跑**四条判据**(判据表 {@link DRAIN_CRITERIA},每条一行末行、各自的命中行;SQL 车道的同一判据是同目录随包的 `drain-check.mysql.sql` / `drain-check.pg.sql`,
5
+ * 每条判据一段、段首标记 `-- drain-check:<判据名>:<后端>`,判据名与本表的 `name` 同一个词):
6
+ * · `skill-grants-rollback`(**回滚**到 7.115.x 前,即引擎从 7.41 退回 7.40):四样任一 ——
7
+ * ① pending 行带 `state.skillGrants`(本次 run 冻结在行上的技能授予行:模型激活 / 子代捕获写下的放行与禁止);
8
+ * ② 行带 `pendingAction.boundGrantDigest`(停驻的 `Skill` 卡的同意绑定);
9
+ * ③ 行所在会话的续跑上下文(`checkpoint-ctx/*.json` 的 `ctx.body.userInvokedSkills`)是非空数组 —— 人用名字点过的技能写下的行**不冻结**进 ①
10
+ * (引擎靠续跑重递请求键重写它们),而 7.115.x 的续跑翻译点不认这个键、不重递。
11
+ * ④ 行所在会话的续跑上下文体里,某只技能(顶层 `skills[]` 或某只子代的 `agents[].skills[]`)声明了**非空** `disallowedTools` —— 那只技能停驻时还没被激活(没有授予行可冻在行上),
12
+ * 回滚后模型再激活它,上一版不读这个键、什么也不拒;前三样都数不到这一形。
13
+ * 7.40 的引擎续跑这种行时都不读:这一 run 的技能禁止行丢失、卡的绑定不核。①② 只看键**在不在**(在却读不出的节 7.40 同样不读,一并数);
14
+ * 这是行形 / 上下文形上的事实,本服务运行期没有同一判据的函数(它不拒这种行),所以判据就写在这一张表里(与 `retired-mode-seed` 同),
15
+ * SQL 车道的孪生是随包 `drain-check.*.sql` 里同名的两段只读 SQL(`-- drain-check:skill-grants-rollback:<后端>`);读不懂的 ctx 档计入 `corrupt`(rc 2:一份读不懂的上下文可能正是带这个键的那一份);
16
+ * · `retired-skill-manifest`(升级到 7.116.0 前,core 7.41.0 退役技能上的 `manifest` 键):pending 行所在会话的续跑上下文(`checkpoint-ctx/*.json`)里,提交体顶层 `skills[]` 或某只子代定义的
17
+ * `agents[].skills[]` 仍带 `manifest`(任何值)—— 升级后续跑顶层那种会被引擎按名拒(行仍 pending,只能取消重提),子代那种不被拒、而是那只子代定义被整只丢掉并留一条警告日志(任务照跑、少一只子代)。命中行点名是哪一层(`member=skills` / `member=agents.skills`)。
18
+ * 「带 `manifest`」的判据是服务里**一只**函数(`src/http/retired-skill-manifest.ts`,提交口的顶层技能门读同一只),经包里脚本隔壁的编译产物 `dist/http/retired-skill-manifest.js` 取 —— 本脚本不写第二份;
19
+ * · `retired-mode-seed`(换钉 7.113.0 前,core 7.39.0 删掉退役 mode-seed 的一次性迁移):pending 行的 `state.inheritedGate` 仍带退役成员;
20
+ * · `permissions-allow-list`(换钉 7.115.0 前,本服务把 `settings.permissions.allow` 从名单改成 allow 规则):pending 行所在会话的续跑上下文
21
+ * (`checkpoint-ctx/*.json` 的信封 `{ sessionId, updatedAt, ctx }`)里,`ctx.body.settings.permissions.allow` 是非空数组,且 `ctx.permissionsAllowReading` 不是 `"statement"`
22
+ * —— 这样的行在 7.115.0 上续跑会被拒(行字节不动;出路按停驻种类分,见随包 `MIGRATION.md` 7.115.0 节)。判据**就是**服务续跑腿拒臂的那一只函数(`src/http/checkpoint-ctx.ts` 的 `allowListReadingUnstated`),
23
+ * 经包里脚本隔壁的编译产物 `dist/http/checkpoint-ctx.js` 取(与缺省数据根经 `dist/local-data-root.js` 取同一姿势;该模块只有类型 import,编出来零运行期依赖)—— 本脚本不写第二份。
24
+ * 扫描骨架(快照 + 账本折成当前行、一致性重读、数据根判定)各条判据共用;判据只回答「这一条 pending 行算不算命中」。
25
+ *
26
+ * ## 为什么是一只脚本,不是一行 grep
27
+ * ① 判据有三条成员(core `refuseRetiredModeSeed`):`autoModeRequested` / `approverPosture` **任何值**(含 JSON null)、
28
+ * `shellGate` **不是 `"always"`**(含 JSON null)。`"always"` 是 7.34.0 起的合法新形,grep 表达不了「值不是 always」;
29
+ * ② core 的 File 店是**快照 + 账本**两只文件(`checkpoints/checkpoints.snapshot.jsonl` 每行一条行,`checkpoints/checkpoints.jsonl` 是快照之后的事件):
30
+ * 压实之后旧行**只在快照里**,只扫账本会把最该排的那批(≤7.105.0 停放的老行)整批漏掉;
31
+ * ③ 账本是追加写:同一条行先 `put` 后 `resolve` / `expire` / `reopen`,只有折叠到**当前状态**才知道它还 pending 没有。
32
+ * ⇒ 本脚本照 core 的同一组事件把两只文件折成当前行,再按上面三条成员判 pending 行。
33
+ *
34
+ * ## 与 core 同判 —— 判据坐标(core 7.39.1 安装树 `node_modules/@sema-agent/core/dist/`;core 改了这些,本脚本要跟着改)
35
+ * · 行形:`stores/file/checkpoint-store.js:61` `isCheckpointRow`(`token` / `scope` / `status` 三个串)。快照行(`:70` `judgeRow`)与账本 `put` 的 `cp`(`:71-78` `judgeEvent`)
36
+ * **同一只判**。不合形的记录 core **跳过并披露、保留旧行**(`stores/file/fs-atomic.js:122` `readJsonlRecords`)—— 升级后那张旧卡照样在、照样被拒;所以本脚本对不合形的 `put`
37
+ * **不覆盖**旧行、计入 `corrupt`(⇒ rc 2),旧卡仍被数到;不合形的快照行(含缺 `scope`)不当行数、计入 `corrupt`。
38
+ * · 事件词:`stores/file/checkpoint-store.js:7-14` 注册表六词(put / resolve / reopen / execution / expire / steer_append);不认的词 core **拒绝重放**
39
+ * (`:16-19` `applyCheckpointEvent` 抛 `ledger_event_unknown`)⇒ 本脚本计入 `unknownEvents`(⇒ rc 2),不替它猜。
40
+ * · 重放序:`stores/file/shared-ledger.js:20-31` 先快照、后账本。压实 `stores/file/shared-ledger.js:63-72` `compact()` 先**原子换新快照**、再**清空账本**。
41
+ * · 退役成员:`core/checkpoint-store.js:739-756` `RETIRED_MODE_SEED_MEMBERS` / `refuseRetiredModeSeed`。
42
+ *
43
+ * ## 只读、不取锁;服务运行中也可以跑,但脚本只对**一致的**快照 + 账本组合下结论
44
+ * 不打开 core 的店(那会取 `checkpoints/LOCK`、与在跑的服务争锁),只读两只 JSONL。服务在写时,两次读之间可能发生压实 ——
45
+ * 先读快照(旧)、压实(新快照含账本里的行,账本清空)、再读账本(空)⇒ 只在账本里的 pending 退役卡两边都读不到,读出一个假的「干净」。
46
+ * 所以读前读后各取两文件的身份(dev / ino / size / mtimeNs),任一变了(压实 = 原子替换 ⇒ ino 变;追加 ⇒ size 变)就**重读**,至多 3 次;
47
+ * 仍不稳 ⇒ rc 2 + 一句话(服务在写:停写或重跑),不给结论。
48
+ *
49
+ * ## 数据根
50
+ * · 带参:`node scripts/drain-check-local.mjs <数据根>` —— 读那个目录;
51
+ * · 不带参:数据根 = **服务自己的那只解析**(`dist/local-data-root.js` 的 `resolveLocalDataRoot`,`loadConfig` 调的同一只函数,脚本不自抄那条链),
52
+ * 所以要在**与服务同一份 env**(同样的数据根相关 env、同一个 home)下跑,读到的才是服务用的那个目录;末行 `root=` 回显实际读了哪个目录。
53
+ * 随包形里 `dist/` 在脚本隔壁;源码树没 build 过又没给根 ⇒ rc 2 说明(见下)。
54
+ * · **数据根缺席 ≠ 干净**:根目录不存在 / 不是目录 / 根下没有 `checkpoints/`(core File 店在后端构造时就建这个目录,所以它是「这里跑过 local 后端」的标记;
55
+ * 根 `LOCK` 是 pidfile、干净退出即删,不能当标记)⇒ rc 2,不给结论 —— 路径打错、env 没对上服务,都会读到一个什么都没有的目录。
56
+ *
57
+ * ## 读数(机读末行 + 退出码)
58
+ * · 每条命中一行:`SKILL-GRANTS-ROW token=<token> sessionId=<id> createdAt=<ms> member=<skillGrants|boundGrantDigest|userInvokedSkills|disallowedTools>` / `SKILL-MANIFEST-ROW token=<token> sessionId=<id> createdAt=<ms> member=<skills|agents.skills>` /
59
+ * `ALLOW-LIST-ROW token=<token> sessionId=<id> createdAt=<ms>` / `RETIRED-ROW token=<token> sessionId=<id> createdAt=<ms> member=<成员>`;
60
+ * · 每条判据一行末行(表序;`RETIRED-MODE-SEED` 那一行恒在最后,7.113.0 起读「最后一行」的下游不受影响;`PERMISSIONS-ALLOW-LIST` 仍是倒数第二行):
61
+ * `SKILL-GRANTS rows=<命中数> pending=<pending 行数> total=<行数> unknownEvents=<n> corrupt=<n> root=<实际读的数据根>`,
62
+ * `RETIRED-SKILL-MANIFEST rows=<命中数> pending=<pending 行数> total=<行数> unknownEvents=<n> corrupt=<n> root=<实际读的数据根>`,
63
+ * `PERMISSIONS-ALLOW-LIST rows=<命中数> pending=<pending 行数> total=<行数> unknownEvents=<n> corrupt=<n> root=<实际读的数据根>`,
64
+ * `RETIRED-MODE-SEED rows=<命中数> pending=<pending 行数> total=<行数> unknownEvents=<n> corrupt=<n> root=<实际读的数据根>`;
65
+ * `corrupt` = 共用扫描的坏记录 + 这条判据自己读不懂的旁路输入(`permissions-allow-list`:读不懂的 ctx 档,文件名进 stderr —— 一份读不懂的 ctx 可能正是某条 pending 行带 `allow` 的那一份);
66
+ * 没读成 / 不能下结论(rc 2 的几种缺席与不稳形)时前五个计数是 `-`、多一个 `reason=<词>`,**不会出现 `rows=0`**(下游读 `rows=0` 即「干净」);
67
+ * · rc 0 = 每条判据都零命中(可换钉 / 可回滚)· rc 1 = 有命中(逐行列出;答完或取消再跑)· rc 2 = 读不懂 / 没读成 / 不能下结论(账本里有本脚本不认的事件词 / 不合形的记录 / 读不懂的 ctx 档 /
68
+ * 数据根缺席 / 缺省根解析不可用 / 判据模块不可用(脚本隔壁没有 `dist/`:源码树没 build)/ 读期间文件一直在变 / 读文件出错 / 用法错)—— 不给结论(缺席 ≠ 干净:一条读不懂的记录可能正是让某行回到 pending 的那一条)。
69
+ * rc 2 与 rc 1 同时成立时(有命中、也有读不懂的记录)报 rc 2,命中行照列。
70
+ */
71
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
72
+ import { homedir } from "node:os";
73
+ import { join } from "node:path";
74
+ import { fileURLToPath } from "node:url";
75
+
76
+ /** 从脚本隔壁的编译产物取来的服务判据(脚本不持第二份实现)。
77
+ * @typedef {{ allowListReadingUnstated: (ctx: Record<string, unknown>) => boolean, retiredSkillManifestLayerOf: (body: unknown) => "skills" | "agents.skills" | undefined }} ServiceCriteria */
78
+
79
+ /** 读期间文件在变时的有界重读次数(含第一次)。 */
80
+ const STABLE_READ_ATTEMPTS = 3;
81
+ /** core 账本事件注册表(`stores/file/checkpoint-store.js:7-14`)。 */
82
+ const KNOWN_LEDGER_EVENTS = new Set(["put", "resolve", "reopen", "execution", "expire", "steer_append"]);
83
+
84
+ /** @param {string} path */
85
+ const dirKind = (path) => {
86
+ try {
87
+ return statSync(path).isDirectory() ? "dir" : "file";
88
+ } catch {
89
+ return "absent";
90
+ }
91
+ };
92
+
93
+ /** 文件全文;不存在 = 空(与 core `readJsonlRecords` 的 ENOENT 臂同);其余错误抛出(由 {@link drainReport} 转 rc 2)。
94
+ * @param {string} path */
95
+ const textOf = (path) => {
96
+ try {
97
+ return readFileSync(path, "utf8");
98
+ } catch (e) {
99
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === "ENOENT") return "";
100
+ throw e;
101
+ }
102
+ };
103
+
104
+ /** @param {string} path */
105
+ const linesOf = (path) => textOf(path).split("\n").filter((l) => l.trim().length > 0);
106
+
107
+ /** @param {string} line */
108
+ const parse = (line) => {
109
+ try {
110
+ const v = JSON.parse(line);
111
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ };
116
+
117
+ /** core `isCheckpointRow`(`stores/file/checkpoint-store.js:61`)逐字:`token` / `scope` / `status` 三个串。快照行与账本 `put` 的 `cp` 同一只判。
118
+ * @param {unknown} x
119
+ * @returns {x is Record<string, unknown> & { token: string, scope: string, status: string }} */
120
+ const isCheckpointRow = (x) =>
121
+ x !== null && typeof x === "object" && typeof (/** @type {Record<string, unknown>} */ (x).token) === "string" && typeof (/** @type {Record<string, unknown>} */ (x).scope) === "string" && typeof (/** @type {Record<string, unknown>} */ (x).status) === "string";
122
+
123
+ /** 一只文件的身份;不存在 = `absent`(账本可以合法不存在);其余 stat 错误抛出。
124
+ * @param {string} path */
125
+ const identityOf = (path) => {
126
+ try {
127
+ const st = statSync(path, { bigint: true });
128
+ return `${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}`;
129
+ } catch (e) {
130
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === "ENOENT") return "absent";
131
+ throw e;
132
+ }
133
+ };
134
+
135
+ /** 两只文件的身份(快照 | 账本)。
136
+ * @param {string} dir */
137
+ const identitiesOf = (dir) => `${identityOf(join(dir, "checkpoints.snapshot.jsonl"))}|${identityOf(join(dir, "checkpoints.jsonl"))}`;
138
+
139
+ /**
140
+ * 读一遍(不判一致性):快照 → 钩子 → 账本,折成当前行。
141
+ * @param {string} dir
142
+ * @param {() => void | Promise<void>} betweenReads
143
+ * @returns {Promise<{ rows: Map<string, Record<string, unknown>>, corrupt: number, unknownEvents: number }>}
144
+ */
145
+ async function foldOnce(dir, betweenReads) {
146
+ /** @type {Map<string, Record<string, unknown>>} token → 行(当前状态) */
147
+ const rows = new Map();
148
+ let corrupt = 0;
149
+ let unknownEvents = 0;
150
+
151
+ // ① 快照:每行一条行;不合形(core `isCheckpointRow`)⇒ core 跳过 ⇒ 本脚本也不当行数,计 corrupt
152
+ for (const line of linesOf(join(dir, "checkpoints.snapshot.jsonl"))) {
153
+ const r = parse(line);
154
+ if (r === undefined || !isCheckpointRow(r)) {
155
+ corrupt++;
156
+ continue;
157
+ }
158
+ rows.set(r.token, r);
159
+ }
160
+
161
+ await betweenReads();
162
+
163
+ // ② 账本:快照之后的事件,照 core `applyCheckpointEvent` 的同一组词折叠(状态相关的三词改 status;其余两词与 pending 判定无关)
164
+ for (const line of linesOf(join(dir, "checkpoints.jsonl"))) {
165
+ const ev = parse(line);
166
+ if (ev === undefined || typeof ev.t !== "string") {
167
+ corrupt++;
168
+ continue;
169
+ }
170
+ if (!KNOWN_LEDGER_EVENTS.has(ev.t)) {
171
+ unknownEvents++; // 新版本写的事件词:本脚本不替它猜(core 对未知词拒绝重放)
172
+ continue;
173
+ }
174
+ if (typeof ev.token !== "string") {
175
+ corrupt++;
176
+ continue;
177
+ }
178
+ if (ev.t === "put") {
179
+ // core:`put` 的 cp 不合形 ⇒ 跳过该事件、保留旧行。本脚本同:不覆盖,计 corrupt(⇒ rc 2),旧行照数。
180
+ if (!isCheckpointRow(ev.cp)) {
181
+ corrupt++;
182
+ continue;
183
+ }
184
+ rows.set(ev.token, ev.cp);
185
+ continue;
186
+ }
187
+ const row = rows.get(ev.token);
188
+ switch (ev.t) {
189
+ case "resolve":
190
+ if (row) row.status = "resolved";
191
+ break;
192
+ case "reopen":
193
+ if (row) row.status = "pending";
194
+ break;
195
+ case "expire":
196
+ if (row) row.status = "expired";
197
+ break;
198
+ default:
199
+ break; // execution / steer_append:不改 status
200
+ }
201
+ }
202
+ return { rows, corrupt, unknownEvents };
203
+ }
204
+
205
+ /**
206
+ * 把 `<dir>` 下的快照 + 账本折成当前行,**只对一致的组合交结果**(导出:测试与 CLI 共用同一只;CLI 不传钩子)。
207
+ * 读前读后各取两文件身份,变了就重读,至多 {@link STABLE_READ_ATTEMPTS} 次;仍变 ⇒ `stable:false`(不交行)。
208
+ * @param {string} dir `<数据根>/checkpoints`
209
+ * @param {{ afterSnapshotRead?: (attempt: number) => void | Promise<void> }} [opts] 测试钩子:快照读完、账本还没读的那一刻(`attempt` 从 1 起)
210
+ * @returns {Promise<{ stable: true, attempts: number, rows: Map<string, Record<string, unknown>>, corrupt: number, unknownEvents: number } | { stable: false, attempts: number }>}
211
+ */
212
+ export async function scanCheckpointDir(dir, opts = {}) {
213
+ for (let attempt = 1; attempt <= STABLE_READ_ATTEMPTS; attempt++) {
214
+ const before = identitiesOf(dir);
215
+ const folded = await foldOnce(dir, async () => {
216
+ await opts.afterSnapshotRead?.(attempt);
217
+ });
218
+ if (identitiesOf(dir) === before) return { stable: true, attempts: attempt, ...folded };
219
+ }
220
+ return { stable: false, attempts: STABLE_READ_ATTEMPTS };
221
+ }
222
+
223
+ /** core `refuseRetiredModeSeed` 的三条成员(`core/checkpoint-store.js:739-756` 逐字):前两条任何值都算(含 null),`shellGate` 不是 "always" 才算。
224
+ * @param {unknown} gate
225
+ * @returns {string | undefined} */
226
+ function retiredMember(gate) {
227
+ if (gate === null || typeof gate !== "object") return undefined;
228
+ const g = /** @type {Record<string, unknown>} */ (gate);
229
+ if (g.autoModeRequested !== undefined) return "autoModeRequested";
230
+ if (g.approverPosture !== undefined) return "approverPosture";
231
+ if (g.shellGate !== undefined && g.shellGate !== "always") return "shellGate";
232
+ return undefined;
233
+ }
234
+
235
+ /** @param {unknown} v */
236
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
237
+
238
+ /**
239
+ * 提交体里有没有技能声明了**非空** `disallowedTools`(数组、至少一员):顶层 `skills[]`,或某只子代定义的 `agents[].skills[]`。体不是对象 / 层不是数组 / 项不是对象 ⇒ 该处不算。
240
+ * @param {unknown} body */
241
+ const declaresDisallowedTools = (body) => {
242
+ if (!isObject(body)) return false;
243
+ const { skills, agents } = /** @type {{ skills?: unknown, agents?: unknown }} */ (body);
244
+ /** @param {unknown} list */
245
+ const anyDeclares = (list) => Array.isArray(list) && list.some((sk) => isObject(sk) && Array.isArray(/** @type {Record<string, unknown>} */ (sk).disallowedTools) && /** @type {unknown[]} */ (/** @type {Record<string, unknown>} */ (sk).disallowedTools).length > 0);
246
+ if (anyDeclares(skills)) return true;
247
+ return Array.isArray(agents) && agents.some((a) => isObject(a) && anyDeclares(/** @type {Record<string, unknown>} */ (a).skills));
248
+ };
249
+
250
+ /**
251
+ * `<数据根>/checkpoint-ctx/*.json` —— 本服务 local 店的续跑上下文,一档一会话,信封 `{ sessionId, updatedAt, ctx }`(`ctx` 是对象;形判据同本服务 `LocalCheckpointStore` 的
252
+ * `CtxRecordSchema` + 对象读者)。目录不存在 = 没有 ctx(没写过);读不懂的档计入 `corrupt`(不猜它属于哪个会话)、文件名回给调用方;读期间被收割删掉的档跳过。
253
+ * @param {string} root
254
+ * @returns {{ bySession: Map<string, Record<string, unknown>>, corrupt: number, unreadable: string[] }} */
255
+ function readCtxIndex(root) {
256
+ const dir = join(root, "checkpoint-ctx");
257
+ /** @type {Map<string, Record<string, unknown>>} */
258
+ const bySession = new Map();
259
+ /** @type {string[]} */
260
+ const unreadable = [];
261
+ /** @type {string[]} */
262
+ let names;
263
+ try {
264
+ names = readdirSync(dir);
265
+ } catch (e) {
266
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === "ENOENT") return { bySession, corrupt: 0, unreadable };
267
+ throw e;
268
+ }
269
+ for (const name of names.sort()) {
270
+ if (!name.endsWith(".json")) continue;
271
+ let text;
272
+ try {
273
+ text = readFileSync(join(dir, name), "utf8");
274
+ } catch (e) {
275
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === "ENOENT") continue; // 读期间被收割删掉
276
+ throw e;
277
+ }
278
+ const rec = parse(text);
279
+ if (rec === undefined || typeof rec.sessionId !== "string" || typeof rec.updatedAt !== "number" || !isObject(rec.ctx)) {
280
+ unreadable.push(name);
281
+ continue;
282
+ }
283
+ bySession.set(rec.sessionId, /** @type {Record<string, unknown>} */ (rec.ctx));
284
+ }
285
+ return { bySession, corrupt: unreadable.length, unreadable };
286
+ }
287
+
288
+ /**
289
+ * 判据表 —— 「哪些 pending 行算命中」。扫描骨架(快照 + 账本折成当前行、一致性重读、数据根判定)各条共用,判据只管这一问:
290
+ * · `tail` / `hit`:末行前缀 / 命中行前缀;
291
+ * · `load(root, service)`:这条判据要的旁路输入(读不懂的记录计入 `corrupt`,`unreadable` 回显进 stderr);`service` = 从编译产物取来的服务判据({@link drainReport} 取一次);
292
+ * · `judge(row, input)`:命中 ⇒ 命中行的尾巴(可为空串),不命中 ⇒ `undefined`。
293
+ * 表序 = 输出序;`retired-mode-seed` 排最后 ⇒ 最后一行仍是 `RETIRED-MODE-SEED …`(7.113.0 起读最后一行的下游)。
294
+ * @type {ReadonlyArray<{ name: string, tail: string, hit: string, load: (root: string, service: ServiceCriteria) => { input: unknown, corrupt: number, unreadable: string[] }, judge: (row: Record<string, unknown>, input: unknown) => string | undefined }>}
295
+ */
296
+ export const DRAIN_CRITERIA = [
297
+ {
298
+ // 回滚到 7.115.x(引擎 7.40)之前:7.41 停驻的行上 7.40 不读的四样(头注第一条)。先看授予节,再看卡的绑定,再看会话上下文里的人点名技能,最后看上下文里还没被激活的技能声明的禁止清单;
299
+ // 命中行点名是哪一样。
300
+ name: "skill-grants-rollback",
301
+ tail: "SKILL-GRANTS",
302
+ hit: "SKILL-GRANTS-ROW",
303
+ load: (root) => {
304
+ const idx = readCtxIndex(root);
305
+ return { input: idx.bySession, corrupt: idx.corrupt, unreadable: idx.unreadable };
306
+ },
307
+ judge: (row, input) => {
308
+ if (isObject(row.state) && Object.hasOwn(/** @type {Record<string, unknown>} */ (row.state), "skillGrants")) return " member=skillGrants";
309
+ if (isObject(row.pendingAction) && Object.hasOwn(/** @type {Record<string, unknown>} */ (row.pendingAction), "boundGrantDigest")) return " member=boundGrantDigest";
310
+ const bySession = /** @type {Map<string, Record<string, unknown>>} */ (input);
311
+ const ctx = typeof row.sessionId === "string" ? bySession.get(row.sessionId) : undefined;
312
+ const body = ctx !== undefined && isObject(ctx.body) ? /** @type {Record<string, unknown>} */ (ctx.body) : undefined;
313
+ const invoked = body?.userInvokedSkills;
314
+ if (Array.isArray(invoked) && invoked.length > 0) return " member=userInvokedSkills";
315
+ return declaresDisallowedTools(body) ? " member=disallowedTools" : undefined;
316
+ },
317
+ },
318
+ {
319
+ // 升级到 7.116.0 之前:停驻、持久化体里技能还带退役 `manifest` 的行(头注第二条判据)。判据函数是服务里那一只(`retiredSkillManifestLayerOf`,经编译产物取);
320
+ // 命中行点名是顶层 `skills[]` 还是子代 `agents[].skills[]`。表序紧跟回滚前那一条 —— `PERMISSIONS-ALLOW-LIST` 仍倒数第二、`RETIRED-MODE-SEED` 仍最后。
321
+ name: "retired-skill-manifest",
322
+ tail: "RETIRED-SKILL-MANIFEST",
323
+ hit: "SKILL-MANIFEST-ROW",
324
+ load: (root, service) => {
325
+ const idx = readCtxIndex(root);
326
+ return { input: { bySession: idx.bySession, layerOf: service.retiredSkillManifestLayerOf }, corrupt: idx.corrupt, unreadable: idx.unreadable };
327
+ },
328
+ judge: (row, input) => {
329
+ const { bySession, layerOf } = /** @type {{ bySession: Map<string, Record<string, unknown>>, layerOf: ServiceCriteria["retiredSkillManifestLayerOf"] }} */ (input);
330
+ const ctx = typeof row.sessionId === "string" ? bySession.get(row.sessionId) : undefined;
331
+ const layer = ctx === undefined ? undefined : layerOf(ctx.body);
332
+ return layer === undefined ? undefined : ` member=${layer}`;
333
+ },
334
+ },
335
+ {
336
+ name: "permissions-allow-list",
337
+ tail: "PERMISSIONS-ALLOW-LIST",
338
+ hit: "ALLOW-LIST-ROW",
339
+ load: (root, service) => {
340
+ const idx = readCtxIndex(root);
341
+ return { input: { bySession: idx.bySession, unstated: service.allowListReadingUnstated }, corrupt: idx.corrupt, unreadable: idx.unreadable };
342
+ },
343
+ judge: (row, input) => {
344
+ const { bySession, unstated } = /** @type {{ bySession: Map<string, Record<string, unknown>>, unstated: (ctx: Record<string, unknown>) => boolean }} */ (input);
345
+ const ctx = typeof row.sessionId === "string" ? bySession.get(row.sessionId) : undefined;
346
+ return ctx !== undefined && unstated(ctx) ? "" : undefined;
347
+ },
348
+ },
349
+ {
350
+ name: "retired-mode-seed",
351
+ tail: "RETIRED-MODE-SEED",
352
+ hit: "RETIRED-ROW",
353
+ load: () => ({ input: undefined, corrupt: 0, unreadable: [] }),
354
+ judge: (row) => {
355
+ const state = isObject(row.state) ? /** @type {Record<string, unknown>} */ (row.state) : {};
356
+ const member = retiredMember(state.inheritedGate);
357
+ return member === undefined ? undefined : ` member=${member}`;
358
+ },
359
+ },
360
+ ];
361
+
362
+ /** 没读成 / 不能下结论的报告:一句人话进 stderr、每条判据一行机读末行进 stdout(计数全 `-`,不出现 `rows=0`),rc 2。
363
+ * @param {string} reason 机读词
364
+ * @param {string} sentence
365
+ * @param {string} rootEcho */
366
+ function unreadableReport(reason, sentence, rootEcho) {
367
+ return { rc: /** @type {const} */ (2), stdout: DRAIN_CRITERIA.map((c) => `${c.tail} rows=- pending=- total=- unknownEvents=- corrupt=- reason=${reason} root=${rootEcho}`), stderr: [sentence] };
368
+ }
369
+
370
+ /**
371
+ * 取服务判据(每个判据模块一次 import + 导出自检)。任一模块加载失败 / 导出缺席 ⇒ 同一条 `criterion-unavailable` 不给结论(报告),不退回手写判据。
372
+ * @param {string} rootEcho
373
+ * @returns {Promise<ServiceCriteria | ReturnType<typeof unreadableReport>>} */
374
+ async function loadServiceCriteria(rootEcho) {
375
+ /** @type {Array<[string, () => Promise<Record<string, unknown>>, string]>} 〔产物相对路径(报告用), 取模块, 导出名〕 */
376
+ const wanted = [
377
+ ["dist/http/checkpoint-ctx.js", () => import("../dist/http/checkpoint-ctx.js"), "allowListReadingUnstated"],
378
+ ["dist/http/retired-skill-manifest.js", () => import("../dist/http/retired-skill-manifest.js"), "retiredSkillManifestLayerOf"],
379
+ ];
380
+ /** @type {Record<string, unknown>} */
381
+ const fns = {};
382
+ for (const [rel, load, name] of wanted) {
383
+ /** @type {Record<string, unknown>} */
384
+ let mod;
385
+ try {
386
+ mod = await load();
387
+ } catch (e) {
388
+ return unreadableReport(
389
+ "criterion-unavailable",
390
+ `cannot load the service's own criterion (${rel} next to scripts/ is not loadable: ${e instanceof Error ? e.message : String(e)}) — no verdict: run the copy inside an installed @sema-agent/server package (or after \`npm run build\`)`,
391
+ rootEcho,
392
+ );
393
+ }
394
+ if (typeof mod?.[name] !== "function") {
395
+ return unreadableReport(
396
+ "criterion-unavailable",
397
+ `cannot use the service's own criterion (${rel} next to scripts/ loaded but does not provide it — the compiled output is incomplete or damaged) — no verdict: reinstall @sema-agent/server and run the copy inside the installed package`,
398
+ rootEcho,
399
+ );
400
+ }
401
+ fns[name] = mod[name];
402
+ }
403
+ return /** @type {ServiceCriteria} */ (fns);
404
+ }
405
+
406
+ /**
407
+ * 一次排空检查的完整报告(导出:CLI 与测试共用)。
408
+ * @param {string} root 数据根
409
+ * @param {{ afterSnapshotRead?: (attempt: number) => void | Promise<void> }} [opts]
410
+ * @returns {Promise<{ rc: 0 | 1 | 2, stdout: string[], stderr: string[] }>}
411
+ */
412
+ export async function drainReport(root, opts = {}) {
413
+ const dir = join(root, "checkpoints");
414
+ switch (dirKind(root)) {
415
+ case "absent":
416
+ return unreadableReport("root-absent", `data root ${root} does not exist — no verdict (absent is not clean): check that this process has the same data-root env as the service, or pass the data root as the argument`, root);
417
+ case "file":
418
+ return unreadableReport("root-not-a-directory", `data root ${root} is not a directory — no verdict: pass the service's data root`, root);
419
+ case "dir":
420
+ break;
421
+ }
422
+ if (dirKind(dir) !== "dir") {
423
+ return unreadableReport(
424
+ "no-checkpoints-dir",
425
+ `no checkpoints/ directory under ${root} — no verdict (absent is not clean): this is not the data root of a local-backend service that has booted (the service creates checkpoints/ when it starts); check that this process has the same data-root env as the service, or pass the data root as the argument`,
426
+ root,
427
+ );
428
+ }
429
+ // 判据的唯一实现在服务里;随包形里编译产物在脚本隔壁。取不到(源码树没 build)⇒ 不给结论 —— 绝不退回一份手写的判据。
430
+ // 模块能加载不等于判据在:产物被截断 / 损坏时 import 照样成功、导出缺席。判据给不出来 ⇒ 同一条「不给结论」,
431
+ // 不许在没有行可判时读成 rows=0,也不许等到调用处抛出(未捕获异常的退出码是 1 = 「有命中」)。
432
+ const service = await loadServiceCriteria(root);
433
+ if ("rc" in service) return service;
434
+ let scan;
435
+ /** @type {Array<{ input: unknown, corrupt: number, unreadable: string[] }>} */
436
+ let inputs;
437
+ try {
438
+ scan = await scanCheckpointDir(dir, opts);
439
+ inputs = DRAIN_CRITERIA.map((c) => c.load(root, service));
440
+ } catch (e) {
441
+ // 读文件出错(权限 / 路径是目录 / …)⇒ 不给结论。未捕获异常的退出码是 1 = 「有命中」,会被下游读成有停放行。
442
+ return unreadableReport("read-error", `cannot read the checkpoint files under ${root}: ${e instanceof Error ? e.message : String(e)} — no verdict`, root);
443
+ }
444
+ if (!scan.stable) {
445
+ return unreadableReport(
446
+ "unstable-read",
447
+ `the checkpoint files under ${dir} changed while being read (${scan.attempts} reads in a row) — the service is writing to its checkpoint store, so no consistent snapshot + ledger could be read: no verdict; stop the service (or its checkpoint traffic) and rerun`,
448
+ root,
449
+ );
450
+ }
451
+ const { rows, corrupt, unknownEvents } = scan;
452
+ /** @type {string[]} */
453
+ const stdout = [];
454
+ /** @type {string[]} */
455
+ const stderr = [];
456
+ /** @type {string[]} */
457
+ const tails = [];
458
+ let anyHit = false;
459
+ let anyUnreadable = unknownEvents > 0 || corrupt > 0;
460
+ DRAIN_CRITERIA.forEach((criterion, i) => {
461
+ const { input, corrupt: ownCorrupt, unreadable } = inputs[i];
462
+ let hits = 0;
463
+ let pending = 0;
464
+ for (const [token, row] of rows) {
465
+ if (row.status !== "pending") continue;
466
+ pending++;
467
+ const tailOfHit = criterion.judge(row, input);
468
+ if (tailOfHit === undefined) continue;
469
+ hits++;
470
+ stdout.push(`${criterion.hit} token=${token} sessionId=${String(row.sessionId)} createdAt=${String(row.createdAt)}${tailOfHit}`);
471
+ }
472
+ if (unreadable.length > 0) stderr.push(`${criterion.name}: unreadable input files (no verdict — one of them may be what makes a pending row count): ${unreadable.join(", ")}`);
473
+ if (ownCorrupt > 0) anyUnreadable = true;
474
+ if (hits > 0) anyHit = true;
475
+ tails.push(`${criterion.tail} rows=${hits} pending=${pending} total=${rows.size} unknownEvents=${unknownEvents} corrupt=${corrupt + ownCorrupt} root=${root}`);
476
+ });
477
+ return { rc: anyUnreadable ? 2 : anyHit ? 1 : 0, stdout: [...stdout, ...tails], stderr };
478
+ }
479
+
480
+ /** CLI 入口:解析实参与缺省根,打印报告,按报告的 rc 退出。 */
481
+ async function main() {
482
+ const usage = "usage: drain-check-local.mjs [<data root>]";
483
+ const args = process.argv.slice(2);
484
+ /** @param {{ rc: number, stdout: string[], stderr: string[] }} r */
485
+ const finish = (r) => {
486
+ for (const l of r.stderr) process.stderr.write(`${l}\n`);
487
+ for (const l of r.stdout) process.stdout.write(`${l}\n`);
488
+ process.exit(r.rc);
489
+ };
490
+ if (args.length > 1 || (args[0] !== undefined && args[0].startsWith("-"))) finish(unreadableReport("usage", usage, "-"));
491
+ /** @type {string} */
492
+ let root;
493
+ if (args[0] !== undefined) {
494
+ root = args[0];
495
+ } else {
496
+ try {
497
+ const { resolveLocalDataRoot } = await import("../dist/local-data-root.js");
498
+ root = resolveLocalDataRoot(process.env, homedir());
499
+ } catch (e) {
500
+ finish(
501
+ unreadableReport(
502
+ "default-root-unavailable",
503
+ `cannot resolve the default data root (dist/local-data-root.js next to scripts/ is not loadable: ${e instanceof Error ? e.message : String(e)}) — run the copy inside an installed @sema-agent/server package (or after \`npm run build\`), or pass the data root as the argument`,
504
+ "-",
505
+ ),
506
+ );
507
+ }
508
+ }
509
+ finish(await drainReport(root));
510
+ }
511
+
512
+ // 作脚本跑才进 CLI;被 import(测试)时只导出函数。
513
+ // 7.113.0 合并复审的修复验证两腿同根发现(主会话按「门轴新代码 fail-closed」收口):入口判定**自己不许失败成「什么都不跑、rc 0」**
514
+ // (rc 0 = 「干净,可换钉」)。两边都取 realpath 再比(`--preserve-symlinks-main` 下 `import.meta.url` 留软链路径、argv[1] 解析成真路径 ⇒ 修前
515
+ // 两边不等 ⇒ main 不跑 ⇒ 静默 rc 0);argv[1] 缺席 / 为 `-`(stdin 导入方)/ 解析不到 ⇒ 当 import,不抛(修前 realpathSync 对 `-` 抛 ENOENT);
516
+ // Node ≥ 22.18 有 `import.meta.main`,在场就以它为准(平台自己的判定,比路径比对可靠)。
517
+ function invokedAsMain() {
518
+ const metaMain = /** @type {{ main?: unknown }} */ (import.meta).main;
519
+ if (typeof metaMain === "boolean") return metaMain;
520
+ const arg = process.argv[1];
521
+ if (arg === undefined || arg === "-" || arg === "") return false;
522
+ try {
523
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(arg);
524
+ } catch {
525
+ return false;
526
+ }
527
+ }
528
+ if (invokedAsMain()) await main();