@whyshu/argent-engine 0.0.0-stage → 0.6.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 (305) hide show
  1. package/MANIFEST.json +307 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +575 -0
  55. package/engine/scripts/billing-mode-daily.sh +12 -0
  56. package/engine/scripts/build-engine-package.mjs +123 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +377 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/config-explain.mjs +278 -0
  81. package/engine/scripts/configure-provider.rb +29 -0
  82. package/engine/scripts/customer-config-migrate.mjs +179 -0
  83. package/engine/scripts/customer-preflight.py +892 -0
  84. package/engine/scripts/customer-readiness.sh +134 -0
  85. package/engine/scripts/daily-report-inbox.py +242 -0
  86. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  87. package/engine/scripts/daily-report.mjs +1310 -0
  88. package/engine/scripts/daily-report.sh +46 -0
  89. package/engine/scripts/data/lip-product-dict.json +306 -0
  90. package/engine/scripts/deliver-customer.mjs +932 -0
  91. package/engine/scripts/delivery-faces.txt +31 -0
  92. package/engine/scripts/desktop-trial.sh +146 -0
  93. package/engine/scripts/dev-window.sh +260 -0
  94. package/engine/scripts/drill-clean-machine.sh +315 -0
  95. package/engine/scripts/dsh-api-client.mjs +215 -0
  96. package/engine/scripts/dsh-backup-scope.sh +83 -0
  97. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  98. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  99. package/engine/scripts/dsh-plane.mjs +153 -0
  100. package/engine/scripts/dsh-portforward.py +223 -0
  101. package/engine/scripts/dsh-preflight.mjs +179 -0
  102. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  103. package/engine/scripts/dsh-release-watch.mjs +458 -0
  104. package/engine/scripts/dsh-remote-check.sh +99 -0
  105. package/engine/scripts/dsh-restart.sh +179 -0
  106. package/engine/scripts/dsh-restore.sh +111 -0
  107. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  108. package/engine/scripts/dsh-snapshot.mjs +116 -0
  109. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  110. package/engine/scripts/dsh-upgrade.mjs +612 -0
  111. package/engine/scripts/dsh-url.sh +86 -0
  112. package/engine/scripts/dsh-web-launch.mjs +155 -0
  113. package/engine/scripts/ensure-store-windows.py +509 -0
  114. package/engine/scripts/entitlements.py +458 -0
  115. package/engine/scripts/env_probe.py +411 -0
  116. package/engine/scripts/feishu-card.mjs +252 -0
  117. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  118. package/engine/scripts/flow-auto-retry.py +454 -0
  119. package/engine/scripts/flow-requirements.json +720 -0
  120. package/engine/scripts/flow-scaffold.py +275 -0
  121. package/engine/scripts/flow-timers-plist.sh +86 -0
  122. package/engine/scripts/flow-timers.sh +182 -0
  123. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  124. package/engine/scripts/flow-trigger.sh +571 -0
  125. package/engine/scripts/flow_billing.py +354 -0
  126. package/engine/scripts/flow_capability_stats.py +314 -0
  127. package/engine/scripts/flow_case.py +194 -0
  128. package/engine/scripts/flow_claim.py +497 -0
  129. package/engine/scripts/flow_config.py +1024 -0
  130. package/engine/scripts/flow_failure.py +588 -0
  131. package/engine/scripts/flow_learn.py +105 -0
  132. package/engine/scripts/flow_ledger.py +235 -0
  133. package/engine/scripts/flow_policy.py +238 -0
  134. package/engine/scripts/flow_preflight.py +339 -0
  135. package/engine/scripts/flow_registry.py +341 -0
  136. package/engine/scripts/flow_retry.py +304 -0
  137. package/engine/scripts/flow_return_to_ops.py +191 -0
  138. package/engine/scripts/flow_source.py +315 -0
  139. package/engine/scripts/flow_sources/__init__.py +53 -0
  140. package/engine/scripts/flow_sources/cases_json.py +106 -0
  141. package/engine/scripts/flow_sources/common.py +428 -0
  142. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  143. package/engine/scripts/flow_stale.py +185 -0
  144. package/engine/scripts/flow_status_reconcile.py +344 -0
  145. package/engine/scripts/flow_triage.py +849 -0
  146. package/engine/scripts/flow_write_verify.py +496 -0
  147. package/engine/scripts/fulfillment.js +586 -0
  148. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  149. package/engine/scripts/gen-preset-rows.mjs +259 -0
  150. package/engine/scripts/init-ml.sh +2275 -0
  151. package/engine/scripts/install-internal-role.sh +190 -0
  152. package/engine/scripts/install-license.mjs +641 -0
  153. package/engine/scripts/install-one-shot.mjs +665 -0
  154. package/engine/scripts/integration_contract.py +220 -0
  155. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  156. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  157. package/engine/scripts/invoice-mapping.example.json +40 -0
  158. package/engine/scripts/invoice-table.sh +194 -0
  159. package/engine/scripts/invoice_carrier_notice.py +279 -0
  160. package/engine/scripts/invoice_fail_closeout.py +297 -0
  161. package/engine/scripts/invoice_lip_runner.py +1508 -0
  162. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  163. package/engine/scripts/invoice_ready_audit.py +191 -0
  164. package/engine/scripts/invoice_runner.py +675 -0
  165. package/engine/scripts/invoice_stageb.py +516 -0
  166. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  167. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  168. package/engine/scripts/lease.py +377 -0
  169. package/engine/scripts/lib/agent-install.mjs +649 -0
  170. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  171. package/engine/scripts/lib/argent_datadir.py +262 -0
  172. package/engine/scripts/lib/argent_filelock.py +507 -0
  173. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  174. package/engine/scripts/lib/cdp_ports.py +197 -0
  175. package/engine/scripts/lib/customer-env.mjs +102 -0
  176. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  177. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  178. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  179. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  180. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  181. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  182. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  183. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  184. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  185. package/engine/scripts/lib/patch-compose.mjs +241 -0
  186. package/engine/scripts/lib/patch-target.mjs +35 -0
  187. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  188. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  189. package/engine/scripts/lib/preset-compat.mjs +127 -0
  190. package/engine/scripts/lib/preset-install.mjs +575 -0
  191. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  192. package/engine/scripts/lib/preset-sync.mjs +59 -0
  193. package/engine/scripts/lib/profile-retire.mjs +97 -0
  194. package/engine/scripts/lib/python-runtime.mjs +190 -0
  195. package/engine/scripts/lib/registry.mjs +72 -0
  196. package/engine/scripts/lib/repo-root.sh +56 -0
  197. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  198. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  199. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  200. package/engine/scripts/lib/settings-panels.mjs +816 -0
  201. package/engine/scripts/lib/stdio_utf8.py +86 -0
  202. package/engine/scripts/lib/wake-auth.mjs +392 -0
  203. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  204. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  205. package/engine/scripts/license-e2e-drill.mjs +426 -0
  206. package/engine/scripts/license-gate.mjs +245 -0
  207. package/engine/scripts/lip_backlog.py +171 -0
  208. package/engine/scripts/lip_table.py +185 -0
  209. package/engine/scripts/local-usage.mjs +214 -0
  210. package/engine/scripts/migrate-providers.mjs +127 -0
  211. package/engine/scripts/migrate-skills.sh +65 -0
  212. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  213. package/engine/scripts/ml-store-ids.json +26 -0
  214. package/engine/scripts/ml_artifact_attribution.py +333 -0
  215. package/engine/scripts/ml_capacity_probe.py +1158 -0
  216. package/engine/scripts/ml_hub_status.py +1556 -0
  217. package/engine/scripts/ml_schema_check.py +115 -0
  218. package/engine/scripts/ml_store_ids.py +139 -0
  219. package/engine/scripts/npm-publish-argent.mjs +499 -0
  220. package/engine/scripts/npm-publish-argent.sh +20 -0
  221. package/engine/scripts/orchestrator.py +13811 -0
  222. package/engine/scripts/pack-plugins.mjs +140 -0
  223. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  224. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  225. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  226. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  227. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  228. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  229. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  230. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  231. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  232. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  233. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  234. package/engine/scripts/plan-role-rename.mjs +125 -0
  235. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  236. package/engine/scripts/plugin-composition.json +15 -0
  237. package/engine/scripts/poc-mcp-host.mjs +455 -0
  238. package/engine/scripts/poll-fulfillment.sh +293 -0
  239. package/engine/scripts/poll-invoice.sh +74 -0
  240. package/engine/scripts/profile-doctor.mjs +686 -0
  241. package/engine/scripts/provider-select.sh +70 -0
  242. package/engine/scripts/provider-switch.sh +146 -0
  243. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  244. package/engine/scripts/python-runtime.sh +78 -0
  245. package/engine/scripts/query_runner.py +310 -0
  246. package/engine/scripts/release-argent.sh +574 -0
  247. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  248. package/engine/scripts/run-tests.sh +256 -0
  249. package/engine/scripts/self-sign-license.mjs +179 -0
  250. package/engine/scripts/session-health-scan.mjs +63 -0
  251. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  252. package/engine/scripts/setup.sh +22 -0
  253. package/engine/scripts/skill-install.mjs +629 -0
  254. package/engine/scripts/sku_translate.py +148 -0
  255. package/engine/scripts/sync-plugins.sh +90 -0
  256. package/engine/scripts/sync-presets.sh +27 -0
  257. package/engine/scripts/sync-scripts.sh +445 -0
  258. package/engine/scripts/telemetry.py +873 -0
  259. package/engine/scripts/ui-copy-count.mjs +220 -0
  260. package/engine/scripts/usage-anchor.sh +136 -0
  261. package/engine/scripts/usage_report.py +531 -0
  262. package/engine/scripts/validate-preset-schema.mjs +208 -0
  263. package/engine/scripts/vertical-init.mjs +323 -0
  264. package/engine/scripts/vertical-lint.mjs +482 -0
  265. package/engine/scripts/wenyao-browser.sh +73 -0
  266. package/engine/scripts/wenyao-fixture-export.py +116 -0
  267. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  268. package/engine/scripts/wenyao-login.sh +34 -0
  269. package/engine/scripts/wenyao-template-map.json +292 -0
  270. package/engine/scripts/wenyao_attach.py +131 -0
  271. package/engine/scripts/wenyao_download_template.py +144 -0
  272. package/engine/scripts/wenyao_fill_template.py +568 -0
  273. package/engine/scripts/wenyao_login.py +145 -0
  274. package/engine/scripts/wenyao_portal.py +633 -0
  275. package/engine/scripts/wenyao_recon.py +230 -0
  276. package/engine/scripts/wenyao_run.py +503 -0
  277. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  278. package/engine/scripts/wenyao_skill_contract.py +156 -0
  279. package/engine/scripts/wenyao_template_fetch.py +175 -0
  280. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  281. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  282. package/engine/scripts/ziniao-keepalive.sh +216 -0
  283. package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
  284. package/engine/scripts/ziniao-windows.sh +153 -0
  285. package/engine/version.mjs +1 -0
  286. package/engine/verticals/README.md +103 -0
  287. package/engine/verticals/_engine-words.baseline.txt +237 -0
  288. package/engine/verticals/_engine-words.json +19 -0
  289. package/engine/verticals/mercadolibre/README.md +43 -0
  290. package/engine/verticals/mercadolibre/config.json +709 -0
  291. package/engine/verticals/mercadolibre/flows.json +706 -0
  292. package/engine/verticals/mercadolibre/manifest.json +538 -0
  293. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  294. package/engine/verticals/mercadolibre/preflight.py +143 -0
  295. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  296. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  297. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  298. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  299. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  300. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  301. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  302. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  303. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  304. package/package.json +21 -4
  305. package/README.md +0 -3
@@ -0,0 +1,1645 @@
1
+ /**
2
+ * `argent license ops` —— 许可**只读运营巡检**(O2:替代 `mongosh`;2026-10-04)
3
+ *
4
+ * ## 为什么有它(判据出处)
5
+ * 《Argent-1.0-退出判据与版本规则》§2.6 O2:**只读运营视图**(在途码 / 失败码 / 设备)替代 `mongosh`。
6
+ * 验收 = 三个问题各一条查询(**在途码数 / 近 7 天失败码 Top / 某设备挂在谁名下**)不碰 `mongosh` 即可回答。
7
+ * 该节同时点名两个**已知陷阱**,本文件把它们变成**可判的守卫**(而不是注释里的提醒):
8
+ * ① 码状态字段是 **`state`**,不是 `status` —— 按 `status` 查**恒空**,会把"有在途码"误读成"没有";
9
+ * ② 席位**不是**存储字段(`used` 不是)—— 必须按 **`releasedAt` 为空的设备码去重**算出来。
10
+ * ⇒ 本文件对这两个陷阱都是**响亮失败**(`field_missing`,exit 2),**绝不静默返回 0**(那正是假绿)。
11
+ *
12
+ * ## 三条纪律(每条都可判)
13
+ * | # | 纪律 | 落点 |
14
+ * |---|---|---|
15
+ * | ① | **只读**:不写任何集合/文件/审计;HTTP **只发 GET**,且⛔**绝不**调 `POST /api/argent/license/release-device`(该端点的 200 成功路径在生产库**从未跑过** ⇒ 视图不得触发任何写) | 全文件零写调用;`httpGet()` 是唯一网络出口且 method 硬编码 `GET` |
16
+ * | ② | **缺数据源就说"缺",不用 0 充数**:每一节都带 `available`;不可用的节给 `reason` + `needs`(要哪个接口/哪份数据),`missingEndpoints` 列出缺的服务端只读接口 | `unavailable()` / `OPS_READ_ENDPOINTS` |
17
+ * | ③ | **凭证只从 600 的文件读、绝不回显**:`--token-file` > `$ARGENT_OPS_TOKEN` > `$ARGENT_OPS_TOKEN_FILE` > `<数据目录>/*.env`(**权限必须是 600**,否则拒用);token 不进 JSON/人读/错误串 | `resolveOpsToken()` |
18
+ *
19
+ * ## A1 / A2 / A3
20
+ * · A1:`0` 读成功(含"成功但空" `empty:true + no_match`)|`2` 用法/环境(未知参数、`--from` 与 `--server` 同时给、快照不可解析、**缺必需字段 `field_missing`**、端点未配置、缺 token、token 文件权限不是 600)|`1` 数据源读失败(服务端连不上/超时、响应不合约)。
21
+ * · A2:`ok/empty/code/reason` 恒在;`$? == json.code`;`--json` 时 stdout 只有一份 JSON;回显**实际生效值**(`source` / `now` / `days` / `filters` / 每节的 `available`)。
22
+ * · A3:**读类 verb,零副作用**(不落审计、不写数据目录、不发写请求),因此**没有** `--apply`/`--yes`;
23
+ * JSON 里以 `readOnly:true` + `sideEffects:"none"` 表达(⛔ **不写** `dryRun` —— 那会让宿主去找一个不存在的 `--apply`)。
24
+ *
25
+ * ## 与判断面清单的关系(⚠️ 如实登记,不假装)
26
+ * 本 verb 是**只读报告**,不做任何门禁/放行判定(J1–J10 全部不涉及)。它内部的阈值
27
+ * (`OPS_TTL_GRACE_MINUTES` / `OPS_DENIED_REPEAT` / 席位口径)是**告警口径**,只影响 `anomalies` 的内容,
28
+ * ⛔ 不影响任何写操作是否放行 ⇒ **未登记为第 11 条 judgment**(登记与否是 Y 位改动的取舍,见守则 §3.2)。
29
+ * 两个陷阱守卫(`state` / `devices`)是**输入形状校验**(与 `parseSubArgs` 同类),不是业务判定。
30
+ *
31
+ * ## 数据源三选一(互斥;默认 = 本机只读产物)
32
+ * | 形态 | 参数 | 能回答什么 |
33
+ * |---|---|---|
34
+ * | 离线快照 | `--from <snapshot.json>` | 五节全给(在途/失败/时间线/席位/异常)—— 快照形态见 `OPS_SNAPSHOT_SHAPE` |
35
+ * | 服务端只读接口 | `--server <base>`(管理员 JWT) | **五节全给**(4 个只读接口自 2026-10-04 起已在服务端就绪,契约见 `whyshu-svr/docs/ops/license-ops-readonly.md`);接口**未部署**或某节读不到时如实标 `available:false + endpoint_missing`(⛔ 不是 0) |
36
+ * | 本机产物(默认) | 无参数 | 本机许可声明(席位/到期)+ 本机激活审计(见过的 `codePrefix` 与结果);需要服务端的节标 `needs_server` |
37
+ *
38
+ * ### 服务端 → 客户端:**四处字段映射**(服务端回报点名;改这里之前先读服务端契约 §5)
39
+ * | # | 服务端给的 | 客户端要的 | 落点 |
40
+ * |---|---|---|---|
41
+ * | ① | `seats.items[].devices[].codePrefix`(**12 位前缀**) | `accounts[].devices[].code`(`OPS_REQUIRED_FIELDS.devices=['code']`;⛔ **不把 64hex 全码补回来**) | `mapServerSeats()` |
42
+ * | ② | `seats.items` / `seats.totals`;`deviceLimitSource: 'deviceLimit'|'seats'`(**机器值**) | `seats.principals` / `seats.totals`(客户端**自己算**);展示串 `deviceLimit 字段`|`seats(缺省跟随 seats)` 由客户端拼 | `mapServerSeats()` / `OPS_SEAT_LIMIT_SOURCE_TEXT` |
43
+ * | ③ | `devices/:codePrefix` 的 **`total`** | `device.found`(判定"命中几处"只看它;`count` 只是本页条数) | `mapServerDevice()` |
44
+ * | ④ | `events` 的 **`total`(窗口内总数)/ `count`(本页条数)** | `timeline.server.total`(窗口内)/ `timeline.count`(本页,= 本页条数)——⚠️ `timeline.scanned` 是**客户端窗口过滤后**的条数(≤ 本页),⛔ 别拿它当"窗口内总数";请求**显式 `limit=200`**(服务端上限) | `serverPageMeta()`(每节的 `server` 元信息)+ `runOpsVerb` 的服务端分支 |
45
+ *
46
+ * ⛔ **本仓库不直连生产库**:`--from` 的快照由服务端**只读**导出(或运营用 `mongosh` 的只读查询自己导出一次),
47
+ * 本仓库既不持有库凭证、也不开库连接 —— 于是"视图会不会误写生产"这个问题在**形态上**就不存在。
48
+ */
49
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
50
+ import { join, resolve } from 'node:path'
51
+ import { resolveDataDir } from './argent-datadir.mjs'
52
+ import { SOURCES, readSource } from './audit.mjs'
53
+ import {
54
+ ACTIVATION_CODE_STATES, ACTIVATION_CODE_TTL_MINUTES,
55
+ CODE_STATUS_PATH, endpointOf, resolveActivationServer, resolveTimeoutMs,
56
+ } from './license-activate.mjs'
57
+ import { licenseVerdict } from './license-verdict.mjs'
58
+
59
+ // ── 参数表(**唯一一份**:`core/lib/license.mjs` 的 SPEC.ops 直接引用它,不各写一遍)──────
60
+ export const OPS_VALUE_FLAGS = ['--from', '--server', '--token-file', '--days', '--now', '--device']
61
+ export const OPS_REPEAT_FLAGS = ['--filter', '--prefix']
62
+ export const OPS_BOOL_FLAGS = ['--local', '--probe', '--json', '--help', '-h']
63
+
64
+ export const OPS_DEFAULT_DAYS = 7
65
+ /** TTL 宽限(分钟):`redeeming` 是 CAS 中间态 + 允许一点时钟偏差 ⇒ 超期超过它才算"该终态没终态"。 */
66
+ export const OPS_TTL_GRACE_MINUTES = 5
67
+ /** 同一前缀的 `*_denied` 事件达到这个数 ⇒ 记一条异常(锁定/探针痕迹)。 */
68
+ export const OPS_DENIED_REPEAT = 3
69
+ /** 设备码**只回前 12 位**(与 `licenseevents` 审计同口径;库里存的才是 64hex)。 */
70
+ export const OPS_DEVICE_ECHO = 12
71
+ /** 时间线最多回多少条(先过滤后截断 —— D1 的教训)。 */
72
+ export const OPS_MAX_EVENTS = 200
73
+ /**
74
+ * 服务端分页上限(契约 §0:`limit` 缺省 50 / **上限 200**,超限 ⇒ 400 `bad_limit`,⛔ 不静默截断)。
75
+ * 客户端**显式**给 200(`--probe` 那次例外:占位探测用服务端缺省),一次尽量把窗口/在途取全。
76
+ */
77
+ export const OPS_PAGE_LIMIT = 200
78
+
79
+ // ── 服务端只读接口的**相对路径**(拼 URL 用;显示的 `path` 在 `OPS_READ_ENDPOINTS`)──────────
80
+ export const CODES_LIST_REL = 'api/argent/license/activation-codes'
81
+ export const EVENTS_REL = 'api/argent/license/events'
82
+ export const SEATS_REL = 'api/argent/license/seats'
83
+ export const DEVICE_LOOKUP_REL = 'api/argent/license/devices/'
84
+
85
+ /** `deviceLimitSource`:服务端给**机器值**(`'deviceLimit'|'seats'`),展示串在客户端拼(映射②)。 */
86
+ export const OPS_SEAT_LIMIT_SOURCE_TEXT = {
87
+ deviceLimit: 'deviceLimit 字段',
88
+ seats: 'seats(缺省跟随 seats)',
89
+ }
90
+
91
+ /** 码的状态机(与服务端 `ActivationCode.state` 逐字一致;引用自 `license-activate.mjs`)。 */
92
+ export const OPS_CODE_STATES = ACTIVATION_CODE_STATES
93
+ /** 在途 = 还没终态的两个状态(O2 的"在途码"定义)。 */
94
+ export const OPS_INFLIGHT_STATES = ['issued', 'redeeming']
95
+ /** `codes-list` 一次拉哪几个状态:`issued,redeeming` 喂 `inflight`(在途),`revoked` 喂 `failures`。 */
96
+ export const OPS_CODES_QUERY_STATES = [...OPS_INFLIGHT_STATES, 'revoked']
97
+
98
+ /** `licenseevents.action` 的**已知**取值(词表来源见文件头;未知动作只登记不判红 —— 服务端可能新增)。 */
99
+ export const OPS_EVENT_ACTIONS = ['code_issue', 'code_redeem', 'code_redeem_denied', 'code_revoke', 'code_revoke_denied', 'device_release']
100
+
101
+ /**
102
+ * **字段契约**(域必需字段)—— 每个集合**非空时必须带全**,否则 `field_missing`(exit 2)。
103
+ * ⛔ 这条判据的存在就是为了那两个陷阱:缺 `state`(写成 `status`)⇒ 报错;缺 `devices` ⇒ 报错。
104
+ * **绝不**把"字段不在"降级成"计数为 0"。
105
+ */
106
+ export const OPS_REQUIRED_FIELDS = {
107
+ codes: ['codePrefix', 'state'],
108
+ events: ['at', 'action', 'result'],
109
+ accounts: ['userId', 'seats', 'devices'],
110
+ devices: ['code'],
111
+ }
112
+
113
+ /** `--filter` 允许的码字段(**本域的词表**;不在这张表里 ⇒ `field_missing`)。 */
114
+ export const OPS_FILTER_FIELDS = ['state', 'codePrefix', 'intent', 'userId', 'failCount']
115
+
116
+ /** 字段缺失时给人看的那句话(**陷阱的唯一文案落点**)。 */
117
+ export const OPS_FIELD_HINTS = {
118
+ state: '码状态字段是 **`state`**(' + OPS_CODE_STATES.join('|') + '),**不是 `status`** —— '
119
+ + '按 `status` 查**恒空**,会把"有在途码"误读成"没有"(O2 已知陷阱①)。请核对导出/接口的字段名。',
120
+ devices: '席位**不是**存储字段(`used` 也不是)—— 必须按 **`releasedAt` 为空的设备码去重**算出来(O2 已知陷阱②)。'
121
+ + '缺 `devices` 就无法回答"用了几个席位",⛔ 不接受用 `used` 字段充数。',
122
+ codePrefix: '码记录缺 `codePrefix`(前 8 位,人工核对用;明文码库里本来就没有)。',
123
+ at: '事件缺 `at`(ISO 时间)—— 没有时间就无法做"近 N 天"时间线。',
124
+ action: '事件缺 `action`(如 `code_issue`/`code_redeem`/`code_redeem_denied`/`code_revoke`/`device_release`)。',
125
+ result: '事件缺 `result`(`ok`|`rejected`)。',
126
+ userId: '账号记录缺 `userId` —— 席位账目按账号归属,缺它无法回答"挂在谁名下"。',
127
+ seats: '账号记录缺 `seats`(许可声明的席位数)。',
128
+ code: '设备记录缺 `code`(64hex 设备码;输出只回前 ' + OPS_DEVICE_ECHO + ' 位)。',
129
+ devicesCodePrefix: '服务端席位行的设备记录缺 `codePrefix`(**12 位前缀**;⛔ 服务端本来就不回 64hex 全码,'
130
+ + '⛔ 客户端也不许把全码补回去)—— 缺它就算不出 `used`(`releasedAt` 为空的设备码去重数)。',
131
+ }
132
+
133
+ /**
134
+ * **服务端只读接口登记表** —— `exists` = 服务端契约里**已就绪**(`whyshu-svr` `92c6233`;
135
+ * 契约文档 `whyshu-svr/docs/ops/license-ops-readonly.md`)。`missingEndpoints` = 本表里 `exists:false` 的
136
+ * **∪ 运行时实测 404(路由不在)** 的 —— 后者用于"接口已就绪但**还没部署**"这个真实窗口:
137
+ * 静态登记说在位、线上却回 404 ⇒ 那一节标 `available:false + endpoint_missing`,⛔ **不是空集**。
138
+ * `rel` = 拼 URL 用的相对路径(`endpointOf()` 拼;`path` 只用于显示,带前导 `/`)。
139
+ * ⛔ 本表**只列读**(GET)。写端点(`POST /api/argent/license/release-device`)**不入表、不探测、不调用**。
140
+ */
141
+ export const OPS_READ_ENDPOINTS = [
142
+ {
143
+ id: 'code-status', method: 'GET', path: '/api/argent/license/activation-code/{prefix}', exists: true,
144
+ rel: 'api/argent/license/activation-code/',
145
+ neededFor: 'inflight',
146
+ note: '**已上线**(管理员 JWT):按 8 位前缀单查一个码的状态;`deviceCodePrefix` 只回前 12 位。'
147
+ + '它只能"按前缀查",没有"列出全部在途码"的能力(后者见 `codes-list`)——'
148
+ + '`codes-list` 读不到时本视图退回按前缀单查(`coverage:"prefix-only"`,⛔ 不假装是全量)。',
149
+ },
150
+ {
151
+ id: 'codes-list', method: 'GET', path: '/api/argent/license/activation-codes?state=issued,redeeming,revoked&limit=N',
152
+ rel: 'api/argent/license/activation-codes', exists: true,
153
+ neededFor: 'inflight',
154
+ note: '**已就绪**(服务端 `92c6233`):全量在途码列表(`activationcodes` 的只读投影,每行只有 '
155
+ + '`codePrefix/state/intent/expiresAt/failCount/deviceCodePrefix`)。客户端一次拉 `issued,redeeming,revoked`:'
156
+ + '前两态喂 `inflight`(⇒ `coverage:"full"`),`revoked` 喂 `failures`(⛔ 不去读明文码/`codeHash`)。'
157
+ + '`hasMore:true` ⇒ 本页不是全量(`coverage:"page-only"`,⛔ 不当成全量)。',
158
+ },
159
+ {
160
+ id: 'events', method: 'GET', path: '/api/argent/license/events?since={iso}&limit=N',
161
+ rel: 'api/argent/license/events', exists: true,
162
+ neededFor: 'timeline',
163
+ note: '**已就绪**(服务端 `92c6233`):`licenseevents` 的只读投影(**只回 `at`/`action`/`result`/`codePrefix`**)。'
164
+ + '`total` = 窗口内总数、`count` = 本页条数(客户端显式 `limit=200`);`hasMore:true` ⇒ 窗口内还有更早的。',
165
+ },
166
+ {
167
+ id: 'seats', method: 'GET', path: '/api/argent/license/seats?limit=N', rel: 'api/argent/license/seats', exists: true,
168
+ neededFor: 'seats',
169
+ note: '**已就绪**(服务端 `92c6233`):席位账目(每账号 `seats` / `deviceLimit` / `deviceLimitSource` / `used` / `free` '
170
+ + '+ 设备列表含 `releasedAt`)。服务端按 `releasedAt` 为空的设备码去重算 `used`;客户端**再算一遍**并把两边的差'
171
+ + '记成异常 `seat_used_mismatch`(⛔ 不盲信任何一边、⛔ 也不接受 `used` 当存储字段)。',
172
+ },
173
+ {
174
+ id: 'device-lookup', method: 'GET', path: '/api/argent/license/devices/{codePrefix}',
175
+ rel: 'api/argent/license/devices/', exists: true,
176
+ neededFor: 'device',
177
+ note: '**已就绪**(服务端 `92c6233`):设备归属(8..64 位 hex 都能查,回 `userId/licenseId/codePrefix/releasedAt/boundAt`)。'
178
+ + '查无此码 = **200 `empty:true`**(⛔ 不是 404);命中处数看 **`total`**(客户端 `device.found`),'
179
+ + '`--probe` 用占位前缀探它时会回 **400 `bad_device`** ——"路由在、参数被拒"是正常的。',
180
+ },
181
+ ]
182
+
183
+ /** 快照形态(`--from` 读的就是它;字段名与服务端集合逐字对齐 ⇒ 导出脚本不用改名)。 */
184
+ export const OPS_SNAPSHOT_SHAPE = {
185
+ generatedAt: 'ISO 时间(可选;缺省用 `--now`)',
186
+ source: '字符串(可选):这份快照是怎么来的(审计/可追溯)',
187
+ codes: '[{ codePrefix(8), state, intent?, issuedAt?, expiresAt?, ttlMinutes?, failCount?, lockedUntil?, userId?, deviceCode? }]',
188
+ events: '[{ at, action, result, codePrefix? }]',
189
+ accounts: '[{ userId, seats, deviceLimit?, devices:[{ code, releasedAt?, boundAt? }] }]',
190
+ }
191
+
192
+ export const OPS_USAGE = [
193
+ '用法:argent license ops [--from <快照.json> | --server <base>] [--days N] [--now <iso>] [--json]',
194
+ '',
195
+ '⛔ 只读:不发任何写请求、不落审计、不写数据目录(本 verb **没有** `--apply`)。',
196
+ '',
197
+ '数据源(三选一,互斥;缺省 = 本机只读产物):',
198
+ ' --from <file> 离线快照(字段形态见 core/lib/license-ops.mjs#OPS_SNAPSHOT_SHAPE);五节全给',
199
+ ' --server <base> 服务端只读接口(管理员 JWT)—— base URL 只给域名,别写成 `…/api/argent`;',
200
+ ' 4 个只读接口已在服务端就绪(activation-codes / events / seats / devices/:prefix)',
201
+ ' ⇒ 五节全给、`coverage:"full"`;接口**未部署**时那一节标 available:false + endpoint_missing',
202
+ ' (⛔ 不是"0 条" —— 用 `--probe` 看实测在不在)',
203
+ '',
204
+ '凭证(只从 600 的文件读、绝不回显;优先级从高到低):',
205
+ ' --token-file <file> > $ARGENT_OPS_TOKEN > $ARGENT_OPS_TOKEN_FILE > <数据目录>/*.env(含 ARGENT_OPS_TOKEN/OPS_TOKEN)',
206
+ '',
207
+ '查询:',
208
+ ' --prefix <8位> 按前缀单查(可重复;回答了"这个码现在什么状态",含已终态的码)',
209
+ ' --local 显式选「本机只读产物」(= 不带 --from/--server 时的缺省形态)',
210
+ ' --device <hex> 某设备挂在谁名下(12 位前缀或 64hex 全码都能给;命中处数看服务端 `total`)',
211
+ ' --filter <字段=值> 筛码,可重复;字段 ∈ ' + OPS_FILTER_FIELDS.join('|'),
212
+ ' --days <N> 时间线窗口(缺省 ' + OPS_DEFAULT_DAYS + ' 天)| --now <iso> 固定"现在"(可复算)',
213
+ ' --probe 逐个**实测**只读接口在不在(只发 GET;写端点一律不探):',
214
+ ' 已在本轮取数里打过的接口用那次实测结果(`via:"live-call"`),其余用占位参数真探一次',
215
+ ' (`via:"explicit"`)⇒ 报 exists/missing,并标出与登记表不一致(`mismatch`)',
216
+ '',
217
+ '⚠️ 两个已知陷阱(本 verb 会**响亮报错**,不会静默给 0):',
218
+ ' · 码状态字段是 `state` 不是 `status`(`--filter status=issued` ⇒ exit 2 + field_missing;',
219
+ ' 服务端对 `?status=` 也回 400 `field_unknown` ⇒ 本视图同样 exit 2)',
220
+ ' · 席位是**算出来的**:`seats` + `deviceLimit`(缺省跟随 seats),`used` = `releasedAt` 为空的设备码去重数',
221
+ ].join('\n')
222
+
223
+ // ── 小工具 ────────────────────────────────────────────────────────────────────
224
+ const failJson = (code, reason, error, extra = {}) => ({
225
+ json: { ok: false, empty: false, code, reason, error, subcommand: 'ops', ...extra },
226
+ human: `❌ ${reason}:${error}`,
227
+ stderr: '', exit: code,
228
+ })
229
+
230
+ /** 设备码**只回前 12 位**(审计同口径;⛔ 绝不出全码)。 */
231
+ export const echoDevice = (code) => String(code == null ? '' : code).slice(0, OPS_DEVICE_ECHO)
232
+
233
+ const parseTime = (v) => {
234
+ if (v == null || v === '') return null
235
+ const t = Date.parse(String(v))
236
+ return Number.isNaN(t) ? null : t
237
+ }
238
+ const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : null)
239
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null)
240
+
241
+ /** 每节不可用时的统一形状(纪律②:说清"缺什么",⛔ 不给 0)。 */
242
+ const unavailable = (reason, needs, note) => ({ available: false, reason, needs, note })
243
+
244
+ /**
245
+ * 节级 `reason` 的取值(**宿主在 `json.<section>.reason` 上分支**)。
246
+ * ⚠️ 它们**不是**顶层 `reason`(顶层只会是 0/1/2 那几种),但仍**登记进** `core/contracts/argent.contracts.json#reasonCodes`
247
+ * (`exit:0`)—— 让"宿主可能要分支的短码"只有**一份**词表(`check-judgment-drift` R1e 就扫这个)。
248
+ * 唯一真源仍是本表;契约里的登记只是入口。
249
+ */
250
+ export const OPS_SECTION_REASONS = {
251
+ endpoint_missing: '服务端**没有**这个只读接口(缺哪个见 `missingEndpoints`)',
252
+ needs_server: '本机产物答不了,要服务端只读接口(`--server`)',
253
+ no_prefix: '没有可查的码前缀(既没 `--prefix`,本机审计里也没有)',
254
+ no_data: '快照里**没有这个集合**(缺席 ≠ 为空,⛔ 不报 0)',
255
+ }
256
+
257
+ /**
258
+ * `empty` 的**唯一判据**:**至少有一节"真答得上来"**,且所有可用的节都没有内容。
259
+ * ⚠️ 三层都在防假绿:
260
+ * · 不可用的节**不参与**判空("答不了"与"没有"是两件事);
261
+ * · **一节都答不了 ⇒ `empty:false`**(⛔ 不是 `no_match`)—— "服务端 4 个只读接口都还没部署"这种情形下
262
+ * 回 `empty:true + no_match` 就是最典型的假绿:读的人会以为"真的没有在途码";
263
+ * · `local`(本机许可/审计附注)**只影响"有没有内容",不算"答得上一节"** —— 否则"远端一节都没读到"
264
+ * 会因为本机那行附注而变成 `empty:true`。
265
+ */
266
+ export function computeEmpty(sections = {}) {
267
+ let anyAnswerable = false
268
+ for (const [name, s] of Object.entries(sections)) {
269
+ if (!s || s.available === false) continue
270
+ if (name === 'local') {
271
+ if (s.present === true || (s.activations && (s.activations.count || 0) > 0)) return false
272
+ continue
273
+ }
274
+ anyAnswerable = true
275
+ if (name === 'seats') { if ((s.principals || []).length > 0) return false; continue }
276
+ if (name === 'device') { if ((s.found || 0) > 0) return false; continue }
277
+ if ((s.count || 0) > 0) return false
278
+ }
279
+ return anyAnswerable
280
+ }
281
+
282
+ // ── 参数解析(表来自本文件;⛔ 未知参数一律拒绝,不静默吃掉别人的参数)──────────────
283
+ /**
284
+ * @returns {{ok:true, values:object, repeat:object, bools:Set<string>} | {ok:false, code:number, reason:string, error:string}}
285
+ */
286
+ export function parseOpsArgs(argv = []) {
287
+ const values = {}
288
+ const repeat = {}
289
+ const bools = new Set()
290
+ for (let i = 0; i < argv.length; i++) {
291
+ const a = argv[i]
292
+ if (a === '--json') continue // CLI 层开关(决定打印形状),不是本层的参数
293
+ if (OPS_BOOL_FLAGS.includes(a)) { bools.add(a); continue }
294
+ if (OPS_VALUE_FLAGS.includes(a) || OPS_REPEAT_FLAGS.includes(a)) {
295
+ const v = argv[i + 1]
296
+ if (v === undefined || String(v).startsWith('--')) {
297
+ return { ok: false, code: 2, reason: 'usage_error', error: `参数 ${a} 缺值(用法见 \`argent license ops --help\`)` }
298
+ }
299
+ if (OPS_REPEAT_FLAGS.includes(a)) (repeat[a] ||= []).push(v)
300
+ else values[a] = v
301
+ i++
302
+ continue
303
+ }
304
+ return { ok: false, code: 2, reason: 'usage_error', error: `未知参数:${a}(用法见 \`argent license ops --help\`)` }
305
+ }
306
+ return { ok: true, values, repeat, bools }
307
+ }
308
+
309
+ // ── 凭证(纪律③)────────────────────────────────────────────────────────────
310
+ const OPS_TOKEN_KEYS = ['ARGENT_OPS_TOKEN', 'OPS_TOKEN']
311
+
312
+ /** 一个文件是不是"仅属主可读写"(0600;symlink 按目标判 —— `statSync` 本就跟链接)。 */
313
+ function modeOf(path) {
314
+ try { return statSync(path).mode & 0o777 } catch { return null }
315
+ }
316
+
317
+ /** 从 `KEY=VALUE` 文本里取第一个命中的键(去掉引号/空白;**不回显内容**)。 */
318
+ export function tokenFromEnvText(text, keys = OPS_TOKEN_KEYS) {
319
+ for (const line of String(text || '').split('\n')) {
320
+ const s = line.trim()
321
+ if (!s || s.startsWith('#')) continue
322
+ const i = s.indexOf('=')
323
+ if (i < 0) continue
324
+ const k = s.slice(0, i).trim().replace(/^export\s+/, '')
325
+ if (!keys.includes(k)) continue
326
+ const v = s.slice(i + 1).trim().replace(/^["']|["']$/g, '')
327
+ if (v) return { key: k, token: v }
328
+ }
329
+ return null
330
+ }
331
+
332
+ /**
333
+ * 解析管理员 JWT(**只读视图用**)。优先级:`--token-file` > `$ARGENT_OPS_TOKEN` > `$ARGENT_OPS_TOKEN_FILE`
334
+ * > `<数据目录>/*.env`(字典序,命中即止)。文件**必须是 0600** —— 否则拒用(凭证不得被同机其它用户读到)。
335
+ * ⛔ 返回值里的 `token` 只进 Authorization 头,**绝不进任何回显**。
336
+ */
337
+ export function resolveOpsToken({ flag = null, env = {}, dataDir = '' } = {}) {
338
+ const miss = { ok: false, token: '', source: '', file: null, mode: null }
339
+ const fromFile = (p, source) => {
340
+ const path = resolve(String(p))
341
+ if (!existsSync(path)) return { ...miss, reason: 'usage_error', source, error: `token 文件不存在:${path}` }
342
+ const mode = modeOf(path)
343
+ if (mode === null) return { ...miss, reason: 'usage_error', source, file: path, error: `token 文件读不到:${path}` }
344
+ if ((mode & 0o077) !== 0) {
345
+ return {
346
+ ...miss, reason: 'usage_error', source, file: path, mode,
347
+ error: `${path} 的权限是 0${mode.toString(8)}(同组/他人可读)⇒ **拒用**。凭证只从 0600 的文件读:`
348
+ + `\`chmod 600 ${path}\` 后重试(⛔ 不要把 token 抄进命令行参数 —— 那会进 shell 历史与进程列表)`,
349
+ }
350
+ }
351
+ let text = ''
352
+ try { text = readFileSync(path, 'utf8') } catch (e) {
353
+ return { ...miss, reason: 'usage_error', source, file: path, mode, error: `token 文件读不出来:${(e && e.message) || e}` }
354
+ }
355
+ const hit = tokenFromEnvText(text)
356
+ if (!hit) {
357
+ return {
358
+ ...miss, reason: 'usage_error', source, file: path, mode,
359
+ error: `${path} 里没有 ${OPS_TOKEN_KEYS.join(' / ')}(只认这两个键;文件的其它内容**没有被使用**)`,
360
+ }
361
+ }
362
+ return { ok: true, token: hit.token, source, file: path, mode, key: hit.key }
363
+ }
364
+
365
+ if (flag) return fromFile(flag, 'flag:--token-file')
366
+ const fromEnv = String(env.ARGENT_OPS_TOKEN || '').trim()
367
+ if (fromEnv) return { ok: true, token: fromEnv, source: 'env:ARGENT_OPS_TOKEN', file: null, mode: null, key: 'ARGENT_OPS_TOKEN' }
368
+ const envFile = String(env.ARGENT_OPS_TOKEN_FILE || '').trim()
369
+ if (envFile) return fromFile(envFile, 'env:ARGENT_OPS_TOKEN_FILE')
370
+ // `<数据目录>/*.env`:只扫描**文件名以 .env 结尾**的(含 symlink;按目标判权限)
371
+ if (dataDir && existsSync(dataDir)) {
372
+ let names = []
373
+ try { names = readdirSync(dataDir).filter((n) => n.endsWith('.env')).sort() } catch { names = [] }
374
+ for (const n of names) {
375
+ const p = join(String(dataDir), n)
376
+ let text = ''
377
+ try { text = readFileSync(p, 'utf8') } catch { continue }
378
+ if (!tokenFromEnvText(text)) continue // 不含这两个键 ⇒ 与它无关(内容不外传)
379
+ return fromFile(p, `datadir:${n}`)
380
+ }
381
+ }
382
+ return {
383
+ ...miss, reason: 'usage_error',
384
+ error: `没有管理员 token(只读视图要它才能调服务端只读接口):用 \`--token-file <600 的文件>\`,或设 `
385
+ + `\`ARGENT_OPS_TOKEN\`/\`ARGENT_OPS_TOKEN_FILE\`,或把 \`ARGENT_OPS_TOKEN=…\` 放进 <数据目录>/*.env(600)。`
386
+ + `⛔ 本仓库不直连生产库、也不接受把 token 写在命令行上(会进 shell 历史)。`,
387
+ }
388
+ }
389
+
390
+ // ── 快照读取 + 字段契约(陷阱守卫)─────────────────────────────────────────────
391
+ /** 读一份只读快照(显式路径不存在 ⇒ 2;不是 JSON/不是对象 ⇒ 2 —— 与 `license verify --file` 同口径)。 */
392
+ export function loadOpsSnapshot(path) {
393
+ const p = resolve(String(path))
394
+ if (!existsSync(p)) return { ok: false, code: 2, reason: 'usage_error', error: `--from 指向的快照不存在:${p}` }
395
+ let text = ''
396
+ try { text = readFileSync(p, 'utf8') } catch (e) {
397
+ return { ok: false, code: 2, reason: 'usage_error', error: `--from 的快照读不出来(${p}):${(e && e.message) || e}` }
398
+ }
399
+ let data = null
400
+ try { data = JSON.parse(text) } catch (e) {
401
+ return { ok: false, code: 2, reason: 'usage_error', error: `--from 的快照不是 JSON(${p}):${(e && e.message) || e}` }
402
+ }
403
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
404
+ return { ok: false, code: 2, reason: 'usage_error', error: `--from 的快照顶层必须是对象(形如 {codes,events,accounts}):${p}` }
405
+ }
406
+ return { ok: true, path: p, data }
407
+ }
408
+
409
+ /**
410
+ * 字段契约检查(**假绿守卫**):集合非空时,域必需字段必须在**至少一条**记录里出现。
411
+ * @returns {{ok:true, missing:[], profile:object} | {ok:false, missing:string[], hint:string}}
412
+ */
413
+ export function checkOpsFields({ codes = [], events = [], accounts = [], devices = [] } = {}) {
414
+ const cols = { codes, events, accounts, devices }
415
+ const missing = []
416
+ const profile = {}
417
+ for (const [name, rows] of Object.entries(cols)) {
418
+ const keys = new Set()
419
+ for (const r of rows) if (r && typeof r === 'object') for (const k of Object.keys(r)) keys.add(k)
420
+ profile[name] = [...keys].sort()
421
+ const required = OPS_REQUIRED_FIELDS[name] || []
422
+ if (!rows.length) continue // 空集合没有"字段"可言(空是合法结论,不是缺字段)
423
+ for (const f of required) if (!keys.has(f)) missing.push(`${name}[].${f}`)
424
+ }
425
+ if (!missing.length) return { ok: true, missing: [], profile }
426
+ const bare = missing.map((m) => m.split('.').pop())
427
+ const hints = [...new Set(bare.map((f) => OPS_FIELD_HINTS[f]).filter(Boolean))]
428
+ return {
429
+ ok: false, missing, profile,
430
+ hint: `数据源缺**域必需字段**:${missing.join('、')}。`
431
+ + (hints.length ? `\n ${hints.join('\n ')}` : '')
432
+ + '\n ⛔ 本视图**不会**把"字段不在"降级成"计数为 0"(那正是把"有在途码"误读成"没有"的假绿)。',
433
+ }
434
+ }
435
+
436
+ // ── 服务端行的**字段契约**(服务端契约 §0 的镜像;字段名按**服务端**的叫法)────────────
437
+ /**
438
+ * 服务端行级必需字段(⛔ 与 `OPS_REQUIRED_FIELDS` **不同名**:服务端设备记录给的是 `codePrefix`(12 位),
439
+ * 不是 64hex 全码 —— 这正是映射①)。缺席时的 `missingFields` 用**服务端口径**的路径串
440
+ * (`items[].state` / `items[].devices[].codePrefix`),与服务端自己的 `field_missing` 逐字可对。
441
+ * ⚠️ 服务端侧两处**刻意不报**(`License.devices` 键缺席 ⇒ 空数组;`License.seats` 缺席 ⇒ 模型缺省 1)
442
+ * 在这里**照样不报** —— 那两处有唯一权威兜底,报错只会造成假红(见服务端契约 §0)。
443
+ */
444
+ export const OPS_SERVER_FIELDS = {
445
+ codes: ['codePrefix', 'state'],
446
+ events: ['at', 'action', 'result'],
447
+ accounts: ['userId', 'devices'],
448
+ devices: ['codePrefix'],
449
+ }
450
+
451
+ /** 服务端字段 → `OPS_FIELD_HINTS` 的键(文案只有一份,两处共用)。 */
452
+ export const OPS_SERVER_FIELD_HINTS = {
453
+ 'items[].codePrefix': 'codePrefix',
454
+ 'items[].state': 'state',
455
+ 'items[].at': 'at',
456
+ 'items[].action': 'action',
457
+ 'items[].result': 'result',
458
+ 'items[].userId': 'userId',
459
+ 'items[].devices': 'devices',
460
+ 'items[].devices[].codePrefix': 'devicesCodePrefix',
461
+ }
462
+
463
+ /**
464
+ * 服务端响应 → **行级字段契约检查**(客户端侧的镜像守卫;`items` 为空是合法结论,不报缺字段)。
465
+ * @returns {{ok:true, missing:[]} | {ok:false, missing:string[], hint:string}}
466
+ */
467
+ export function checkServerRows({ codes = [], events = [], accounts = [], devices = [] } = {}) {
468
+ const cols = [
469
+ ['items[]', codes, OPS_SERVER_FIELDS.codes],
470
+ ['items[]', events, OPS_SERVER_FIELDS.events],
471
+ ['items[]', accounts, OPS_SERVER_FIELDS.accounts],
472
+ ['items[].devices[]', devices, OPS_SERVER_FIELDS.devices],
473
+ ]
474
+ const missing = []
475
+ for (const [prefix, rows, required] of cols) {
476
+ if (!rows.length) continue
477
+ const keys = new Set()
478
+ for (const r of rows) if (r && typeof r === 'object') for (const k of Object.keys(r)) keys.add(k)
479
+ for (const f of required) if (!keys.has(f)) missing.push(`${prefix}.${f}`)
480
+ }
481
+ const uniq = [...new Set(missing)]
482
+ if (!uniq.length) return { ok: true, missing: [] }
483
+ const hints = [...new Set(uniq.map((m) => OPS_FIELD_HINTS[OPS_SERVER_FIELD_HINTS[m]]).filter(Boolean))]
484
+ return {
485
+ ok: false, missing: uniq,
486
+ hint: `服务端响应缺**行级必需字段**:${uniq.join('、')}。`
487
+ + (hints.length ? `\n ${hints.join('\n ')}` : '')
488
+ + '\n ⛔ 本视图**不会**把"字段不在"降级成"计数为 0"(服务端契约 §0:这种情形它回 200 但 `ok:false`,'
489
+ + '判定只看 `ok`,⛔ 不看状态码)。',
490
+ }
491
+ }
492
+
493
+ // ── 服务端响应 → 客户端形状(**四处映射陷阱的唯一落点**)──────────────────────────
494
+ /**
495
+ * 映射①:`codes-list.items` → `codes`(`state` 原样透传;`deviceCodePrefix` 只回 12 位)。
496
+ * ⛔ 白名单式投影:服务端哪天多回一个字段也不会漏进输出(PII/明文码的防线在这里,不靠服务端单侧)。
497
+ */
498
+ export function mapServerCodes(items = []) {
499
+ return (Array.isArray(items) ? items : []).filter((r) => r && typeof r === 'object').map((r) => ({
500
+ codePrefix: r.codePrefix ?? null,
501
+ state: r.state ?? null,
502
+ intent: r.intent ?? null,
503
+ expiresAt: r.expiresAt ?? null,
504
+ failCount: num(r.failCount) || 0,
505
+ deviceCodePrefix: r.deviceCodePrefix ? echoDevice(r.deviceCodePrefix) : null,
506
+ }))
507
+ }
508
+
509
+ /** 映射(时间线):`events.items` → `events`(**只** at/action/result/codePrefix 四个字段)。 */
510
+ export function mapServerEvents(items = []) {
511
+ return (Array.isArray(items) ? items : []).filter((r) => r && typeof r === 'object').map((r) => ({
512
+ at: r.at ?? null, action: r.action ?? null, result: r.result ?? null, codePrefix: r.codePrefix ?? null,
513
+ }))
514
+ }
515
+
516
+ /**
517
+ * 映射①②:`seats.items` → `accounts`:`devices[].codePrefix → code`(值仍是 **12 位前缀**,
518
+ * ⛔ **不把 64hex 全码补回来**);`deviceLimitSource` 的**机器值**留在 `deviceLimitSourceRaw`,
519
+ * 展示串(`'deviceLimit 字段'`|`'seats(缺省跟随 seats)'`)由客户端拼。
520
+ */
521
+ export function mapServerSeats(items = []) {
522
+ return (Array.isArray(items) ? items : []).filter((r) => r && typeof r === 'object').map((r) => {
523
+ const src = r.deviceLimitSource === 'deviceLimit' || r.deviceLimitSource === 'seats' ? r.deviceLimitSource : null
524
+ return {
525
+ userId: r.userId ?? null,
526
+ seats: num(r.seats),
527
+ deviceLimit: num(r.deviceLimit),
528
+ deviceLimitSourceRaw: src,
529
+ serverUsed: num(r.used),
530
+ serverFree: num(r.free),
531
+ devices: (Array.isArray(r.devices) ? r.devices : []).filter((d) => d && typeof d === 'object')
532
+ .map((d) => ({ code: d.codePrefix ?? null, releasedAt: d.releasedAt ?? null, boundAt: d.boundAt ?? null })),
533
+ }
534
+ })
535
+ }
536
+
537
+ /** 映射②:`deviceLimitSource` 机器值 → 展示串(服务端契约 §3 的文案规定)。 */
538
+ export const seatLimitSourceText = (raw, fallback) => (raw && OPS_SEAT_LIMIT_SOURCE_TEXT[raw]) || fallback
539
+
540
+ /**
541
+ * 映射③:`devices/:codePrefix` 响应 → `device` 一节。
542
+ * ⚠️ **命中处数看服务端 `total`**(`empty:true ⇔ total === 0`),`count` 只是本页条数(超 200 时 `hasMore`)。
543
+ */
544
+ export function mapServerDevice(body = {}, { query = '' } = {}) {
545
+ const items = (Array.isArray(body.items) ? body.items : []).filter((r) => r && typeof r === 'object')
546
+ const byUser = new Map()
547
+ for (const r of items) {
548
+ const k = String(r.userId ?? '(未知)')
549
+ const list = byUser.get(k) || []
550
+ list.push({
551
+ codePrefix: r.codePrefix ? echoDevice(r.codePrefix) : null,
552
+ licenseId: r.licenseId ?? null,
553
+ releasedAt: r.releasedAt ?? null,
554
+ boundAt: r.boundAt ?? null,
555
+ })
556
+ byUser.set(k, list)
557
+ }
558
+ const total = num(body.total)
559
+ return {
560
+ available: true,
561
+ coverage: body.hasMore === true ? 'page-only' : 'full',
562
+ query: body.query ? echoDevice(body.query) : echoDevice(query),
563
+ found: total === null ? items.length : total, // ⚠️ 映射③:判定用 total(含已释放的绑定)
564
+ total: total === null ? items.length : total,
565
+ count: items.length,
566
+ empty: body.empty === true,
567
+ hasMore: body.hasMore === true,
568
+ codeMatches: [],
569
+ accounts: [...byUser.entries()].map(([userId, devices]) => ({ userId, devices })),
570
+ server: { endpoint: 'devices/{codePrefix}', note: '含**已释放**的绑定(`releasedAt` 非空 = 曾挂过)' },
571
+ definition: '**设备归属**:服务端回"这个码曾挂过哪些账号"(含已释放)。'
572
+ + '命中处数看 `total`(`empty:true ⇔ total === 0` ⇒ 查无此码是**空集**,不是错误/404);'
573
+ + `⛔ 设备码只回前 ${OPS_DEVICE_ECHO} 位。`,
574
+ }
575
+ }
576
+
577
+ // ── 分析(纯函数:喂数据 → 五节;夹具/内存桩直接调它)────────────────────────────
578
+ /** 码的到期时刻:优先 `expiresAt`;缺则 `issuedAt + ttlMinutes`(缺 `ttlMinutes` 用客户端的 TTL 常量)。 */
579
+
580
+ export function expiryMsOf(code) {
581
+ const e = parseTime(code && code.expiresAt)
582
+ if (e !== null) return e
583
+ const i = parseTime(code && code.issuedAt)
584
+ if (i === null) return null
585
+ const ttl = num(code && code.ttlMinutes)
586
+ return i + (ttl && ttl > 0 ? ttl : ACTIVATION_CODE_TTL_MINUTES) * 60000
587
+ }
588
+
589
+ /** 在途码一条(含 TTL 剩余/超期分钟;`overdueMinutes > 0` 即"超 TTL 未终态")。 */
590
+ export function inflightEntry(code, now) {
591
+ const exp = expiryMsOf(code)
592
+ const left = exp === null ? null : Math.round((exp - now) / 60000)
593
+ const overdue = left !== null && left < -OPS_TTL_GRACE_MINUTES ? -left : 0
594
+ return {
595
+ codePrefix: code.codePrefix,
596
+ state: code.state,
597
+ intent: code.intent ?? null,
598
+ issuedAt: code.issuedAt ?? null,
599
+ expiresAt: code.expiresAt ?? iso(exp),
600
+ ttlLeftMinutes: left,
601
+ overdueMinutes: overdue,
602
+ failCount: num(code.failCount) || 0,
603
+ lockedUntil: code.lockedUntil ?? null,
604
+ userId: code.userId ?? code.issuedBy ?? null,
605
+ deviceCodePrefix: code.deviceCodePrefix ? echoDevice(code.deviceCodePrefix)
606
+ : (code.deviceCode ? echoDevice(code.deviceCode) : null),
607
+ }
608
+ }
609
+
610
+ /**
611
+ * 五节分析(**纯函数**,不读文件/不发网络 ⇒ 夹具与内存桩直接调)。
612
+ * @param {{codes?:Array, events?:Array, accounts?:Array}} data
613
+ * @param {{now?:number, days?:number, filters?:Array<{field:string,value:string}>, device?:string}} [opts]
614
+ */
615
+ export function analyzeOps(data = {}, opts = {}) {
616
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now()
617
+ const days = Number.isFinite(opts.days) ? opts.days : OPS_DEFAULT_DAYS
618
+ const filters = Array.isArray(opts.filters) ? opts.filters : []
619
+ const deviceQuery = String(opts.device || '').trim().toLowerCase()
620
+
621
+ const codes = Array.isArray(data.codes) ? data.codes.filter((c) => c && typeof c === 'object') : []
622
+ const events = Array.isArray(data.events) ? data.events.filter((e) => e && typeof e === 'object') : []
623
+ const accounts = Array.isArray(data.accounts) ? data.accounts.filter((a) => a && typeof a === 'object') : []
624
+
625
+ const match = (c) => filters.every((f) => String(c[f.field] ?? '') === String(f.value))
626
+ const filtered = codes.filter(match)
627
+
628
+ // ① 在途码
629
+ const inflightAll = filtered.filter((c) => OPS_INFLIGHT_STATES.includes(c.state))
630
+ const inflight = inflightAll.map((c) => inflightEntry(c, now))
631
+ .sort((a, b) => String(a.expiresAt || '').localeCompare(String(b.expiresAt || '')))
632
+
633
+ // ② 失败码(failCount>0 或 revoked)+ 拒绝事件计数(口径 = 数据源里的全部事件,不随 --days 变)
634
+ const failedCodes = filtered
635
+ .filter((c) => (num(c.failCount) || 0) > 0 || c.state === 'revoked')
636
+ .map((c) => ({
637
+ codePrefix: c.codePrefix, state: c.state, failCount: num(c.failCount) || 0,
638
+ lockedUntil: c.lockedUntil ?? null, userId: c.userId ?? c.issuedBy ?? null,
639
+ }))
640
+ const deniedCounts = {}
641
+ for (const e of events) if (String(e.action || '').endsWith('_denied')) deniedCounts[e.action] = (deniedCounts[e.action] || 0) + 1
642
+ const byState = {}
643
+ for (const c of failedCodes) byState[c.state || '(未知)'] = (byState[c.state || '(未知)'] || 0) + 1
644
+
645
+ // ③ 时间线(近 N 天;**只** action/result/codePrefix —— 先过滤后截断,D1 的教训)
646
+ const since = now - days * 86400000
647
+ const inWindow = events
648
+ .map((e) => ({ at: e.at ?? null, ms: parseTime(e.at), action: e.action ?? null, result: e.result ?? null, codePrefix: e.codePrefix ?? null }))
649
+ .filter((e) => e.ms !== null && e.ms >= since && e.ms <= now)
650
+ .sort((a, b) => a.ms - b.ms)
651
+ const timeline = inWindow.slice(-OPS_MAX_EVENTS).map(({ ms, ...rest }) => { void ms; return rest })
652
+ const counts = {}
653
+ for (const e of inWindow) { const k = `${e.action}/${e.result}`; counts[k] = (counts[k] || 0) + 1 }
654
+ // 未知动作**不判红**(服务端可能新增动作),只单独列出来给运营看(`unknownActions`)
655
+ const unknownActions = [...new Set(events.map((e) => e.action).filter((a) => a && !OPS_EVENT_ACTIONS.includes(a)))]
656
+
657
+ // ④ 席位账目(`used` **算出来**:`releasedAt` 为空的设备码去重数;`deviceLimit` 缺省跟随 `seats`)
658
+ const seatMismatches = []
659
+ const seatAccounts = accounts.map((a) => {
660
+ const devices = (Array.isArray(a.devices) ? a.devices : []).filter((d) => d && typeof d === 'object')
661
+ const active = devices.filter((d) => !d.releasedAt)
662
+ const used = new Set(active.map((d) => String(d.code || ''))).size
663
+ const dl = num(a.deviceLimit)
664
+ const deviceLimit = dl === null ? num(a.seats) : dl
665
+ const rawSource = a.deviceLimitSourceRaw === 'deviceLimit' || a.deviceLimitSourceRaw === 'seats' ? a.deviceLimitSourceRaw : null
666
+ // ⚠️ 数据源自己也回了 `used`(服务端席位账目就有)⇒ **再算一遍并比对**:
667
+ // 已释放的设备**不**计入 `used`(映射②/陷阱②);两边不一致 ⇒ 异常 `seat_used_mismatch`(⛔ 不盲信任一边)。
668
+ const serverUsed = num(a.serverUsed)
669
+ if (serverUsed !== null && serverUsed !== used) {
670
+ seatMismatches.push({
671
+ id: 'seat_used_mismatch', severity: 'alert', subject: a.userId ?? '(未知账号)',
672
+ detail: `席位口径不一致:数据源回 \`used=${serverUsed}\`,本视图按"**\`releasedAt\` 为空**的设备码去重"算出 \`used=${used}\``
673
+ + `(该账号 ${devices.length} 条设备记录,其中已释放 ${devices.length - active.length} 条不占席位)`,
674
+ needs: '核对设备记录:已释放(`releasedAt` 非空)的绑定**不占席位**;'
675
+ + '若两边只差"同码重复/前缀去重",以设备记录为准(本视图口径见 `definition.seat`)',
676
+ })
677
+ }
678
+ return {
679
+ userId: a.userId, seats: num(a.seats), deviceLimit,
680
+ deviceLimitSourceRaw: rawSource,
681
+ deviceLimitSource: seatLimitSourceText(rawSource, dl === null ? 'seats(缺省跟随 seats)' : 'deviceLimit 字段'),
682
+ used, free: deviceLimit === null ? null : Math.max(0, deviceLimit - used),
683
+ devices: devices.map((d) => ({ codePrefix: echoDevice(d.code), releasedAt: d.releasedAt ?? null, boundAt: d.boundAt ?? null })),
684
+ }
685
+ })
686
+ const seatTotals = {
687
+ accounts: seatAccounts.length,
688
+ seats: seatAccounts.reduce((n, a) => n + (a.seats || 0), 0),
689
+ deviceLimit: seatAccounts.reduce((n, a) => n + (a.deviceLimit || 0), 0),
690
+ used: seatAccounts.reduce((n, a) => n + a.used, 0),
691
+ free: seatAccounts.reduce((n, a) => n + (a.free || 0), 0),
692
+ }
693
+
694
+ // ⑤ 异常清单(**只看不判**:不影响任何写操作的放行,见文件头"与判断面清单的关系")
695
+ const anomalies = []
696
+ for (const c of inflight) {
697
+ if (c.overdueMinutes > 0) {
698
+ anomalies.push({
699
+ id: 'inflight_over_ttl', severity: 'warn', subject: c.codePrefix,
700
+ detail: `在途码超 TTL 未终态:state=${c.state},超期 ${c.overdueMinutes} 分钟(宽限 ${OPS_TTL_GRACE_MINUTES} 分钟)`,
701
+ needs: '让服务端把它终态化(expired/redeemed)或核对 TTL 判据;⛔ 本视图不会替它改库',
702
+ })
703
+ }
704
+ }
705
+ const byDevice = new Map()
706
+ for (const a of seatAccounts) {
707
+ for (const d of a.devices) {
708
+ if (d.releasedAt) continue
709
+ const k = String(d.codePrefix || '')
710
+ if (!k) continue
711
+ const list = byDevice.get(k) || []
712
+ list.push(a.userId)
713
+ byDevice.set(k, list)
714
+ }
715
+ }
716
+ for (const [codePrefix, users] of byDevice) {
717
+ const uniq = [...new Set(users)]
718
+ if (uniq.length > 1) {
719
+ anomalies.push({
720
+ id: 'device_multi_account', severity: 'alert', subject: codePrefix,
721
+ detail: `同一设备码挂在 ${uniq.length} 个账号名下且 \`releasedAt\` 均为空:${uniq.join('、')}(设备码只显示前 ${OPS_DEVICE_ECHO} 位)`,
722
+ needs: '确认哪台机器该保留:走受支持的释放端点(POST /api/argent/license/release-device)后重签;'
723
+ + '⛔ 只读视图**不会**代触发释放(该端点的 200 路径在生产库尚未跑过)',
724
+ })
725
+ }
726
+ }
727
+ for (const a of seatAccounts) {
728
+ if (a.deviceLimit !== null && a.used > a.deviceLimit) {
729
+ anomalies.push({
730
+ id: 'device_over_limit', severity: 'alert', subject: a.userId,
731
+ detail: `未释放设备数 ${a.used} 超过 deviceLimit ${a.deviceLimit}(seats ${a.seats})—— 席位账目与设备记录不自洽`,
732
+ needs: '核对该账号的设备记录(是否有漏写 releasedAt 的僵尸绑定)',
733
+ })
734
+ }
735
+ }
736
+ // 映射②的守卫:数据源回的 `used` 与"按 releasedAt 去重算出来"的必须一致(⛔ 不盲信任一边)
737
+ for (const m of seatMismatches) anomalies.push({ ...m, needs: m.needs + `(${SEAT_DEFINITION})` })
738
+ const releaseRejected = events.filter((e) => e.action === 'device_release' && e.result === 'rejected')
739
+ if (releaseRejected.length) {
740
+ anomalies.push({
741
+ id: 'release_probe_rejected', severity: 'alert', subject: 'device_release/rejected',
742
+ detail: `审计里出现 ${releaseRejected.length} 条 \`device_release/rejected\`(探针痕迹):`
743
+ + `该端点的 **200 成功路径在生产库仍未跑过**(O2/L2 的存量条件)`,
744
+ needs: '先按 L2 判据做一次**双验收**的真释放(端点在位 + 200 + 审计行),再据此判断拒绝是权限还是实现问题',
745
+ })
746
+ }
747
+ for (const [action, n] of Object.entries(deniedCounts)) {
748
+ if (n < OPS_DENIED_REPEAT) continue
749
+ const prefixes = [...new Set(events.filter((e) => e.action === action).map((e) => e.codePrefix).filter(Boolean))]
750
+ anomalies.push({
751
+ id: 'denied_repeat', severity: 'warn', subject: action,
752
+ detail: `${action} 出现 ${n} 次(≥${OPS_DENIED_REPEAT})${prefixes.length ? `,涉及前缀 ${prefixes.slice(0, 5).join('、')}` : ''}`
753
+ + ' —— 可能是锁定/双花/转发,也可能只是运营反复试',
754
+ needs: '按前缀逐个 `GET /api/argent/license/activation-code/:prefix` 看 state/failCount/lockedUntil',
755
+ })
756
+ }
757
+
758
+ // ⑥ 设备归属(`--device`;答 O2 的第三个问题)
759
+ let device = null
760
+ if (deviceQuery) {
761
+ const hitCode = (v) => String(v || '').toLowerCase().startsWith(deviceQuery)
762
+ const devMatches = []
763
+ for (const c of codes) {
764
+ const dc = c.deviceCode || c.deviceCodePrefix
765
+ if (dc && hitCode(dc)) devMatches.push({ codePrefix: c.codePrefix, state: c.state, deviceCodePrefix: echoDevice(dc) })
766
+ }
767
+ const devAccounts = seatAccounts
768
+ .map((a) => ({ userId: a.userId, devices: a.devices.filter((d) => hitCode(d.codePrefix)) }))
769
+ .filter((a) => a.devices.length)
770
+ device = { available: true, query: echoDevice(deviceQuery), codeMatches: devMatches, accounts: devAccounts, found: devMatches.length + devAccounts.length }
771
+ }
772
+
773
+ const empty = inflight.length === 0 && failedCodes.length === 0 && timeline.length === 0
774
+ && seatAccounts.length === 0 && anomalies.length === 0 && (!device || device.found === 0)
775
+
776
+ return {
777
+ now: iso(now), days, since: iso(since),
778
+ filters: filters.map((f) => `${f.field}=${f.value}`),
779
+ inflight: { available: true, coverage: 'full', states: [...OPS_INFLIGHT_STATES], count: inflight.length, codes: inflight },
780
+ failures: {
781
+ available: true, coverage: 'full', count: failedCodes.length, byState, byReason: deniedCounts,
782
+ scope: '数据源内的全部事件(不随 --days 收窄)', codes: failedCodes,
783
+ },
784
+ timeline: {
785
+ available: true, coverage: 'full', days, from: iso(since), to: iso(now), count: timeline.length,
786
+ returned: timeline.length, scanned: inWindow.length, counts, events: timeline, unknownActions,
787
+ },
788
+ seats: { available: true, coverage: 'full', principals: seatAccounts, totals: seatTotals, definition: SEAT_DEFINITION },
789
+ anomalies: { available: true, count: anomalies.length, items: anomalies },
790
+ ...(device ? { device } : {}),
791
+ empty,
792
+ }
793
+ }
794
+
795
+ /** 席位口径的**唯一文案落点**(陷阱②;文档只引用不复述)。 */
796
+ export const SEAT_DEFINITION = '席位 = `seats`(许可声明的席位数);未释放设备数 `used` = 设备记录里 **`releasedAt` 为空**的设备码**去重**数;'
797
+ + '`deviceLimit`(允许绑定的设备上限)缺省**跟随 `seats`**。⛔ `used` **不是**存储字段 —— 直接查它会得到空/错值(O2 陷阱②)。'
798
+
799
+ // ── 本机只读产物(默认数据源)────────────────────────────────────────────────
800
+ /** 读本机许可声明 + 本机激活审计(**只读**;审计里本来就只有 `codePrefix`,不会有明文码)。 */
801
+ export function readLocalOps({ env = {}, dataDir, now = Date.now() } = {}) {
802
+ const dir = dataDir || resolveDataDir({ env }).dir
803
+ const v = licenseVerdict({ env, now })
804
+ const src = SOURCES.find((s) => s.id === 'license-activation')
805
+ const audit = src ? readSource(dir, src) : { exists: false, rows: [], file: null, error: null }
806
+ const activations = (audit.rows || []).map((r) => {
807
+ const d = r.data || {}
808
+ const a = d.activation || {}
809
+ return {
810
+ at: d.at ?? r.at ?? null,
811
+ ok: d.ok === true,
812
+ reason: d.reason ?? null,
813
+ codePrefix: a.codePrefix ?? null,
814
+ deviceCodePrefix: d.device && d.device.code ? echoDevice(d.device.code) : null,
815
+ server: d.server && d.server.host ? d.server.host : null,
816
+ }
817
+ })
818
+ return {
819
+ dataDir: dir,
820
+ license: {
821
+ available: true,
822
+ licenseFile: v.path,
823
+ state: v.state,
824
+ reason: v.reason ?? null,
825
+ present: v.reason !== 'missing' && v.reason !== 'unreadable',
826
+ userId: v.userId || null,
827
+ seats: v.seats ?? null,
828
+ capabilities: v.capabilities || [],
829
+ verticals: v.verticals || [],
830
+ expiresAt: v.expiresAt || null,
831
+ graceDays: v.graceDays ?? null,
832
+ signatureVersion: v.signatureVersion === 2 ? 2 : (v.signatureVersion === 1 ? 1 : null),
833
+ deviceBinding: v.device ? v.device.binding : null,
834
+ deviceCodePrefix: v.device && v.device.code ? echoDevice(v.device.code) : null,
835
+ },
836
+ activations: { available: true, file: audit.file, exists: audit.exists === true, error: audit.error || null, count: activations.length, rows: activations },
837
+ prefixes: [...new Set(activations.map((a) => a.codePrefix).filter(Boolean))],
838
+ }
839
+ }
840
+
841
+ // ── 服务端只读接口(只发 GET)───────────────────────────────────────────────
842
+ /**
843
+ * **唯一网络出口**:只发 `GET`(method 硬编码 —— 想写也写不了),带管理员 JWT,超时用 activate 的同一判据。
844
+ * @returns {Promise<{status:number|null, body:object|null, network:string|null, error:string}>}
845
+ */
846
+ export async function httpGet({ endpoint, token, timeoutMs, fetchImpl }) {
847
+ const ctl = new AbortController()
848
+ const timer = setTimeout(() => ctl.abort(), timeoutMs)
849
+ try {
850
+ const res = await fetchImpl(endpoint, {
851
+ method: 'GET', // ⛔ 硬编码:本视图绝不发写方法
852
+ headers: { accept: 'application/json', authorization: `Bearer ${token}` },
853
+ signal: ctl.signal,
854
+ })
855
+ const text = String(await res.text())
856
+ let body = null
857
+ try { body = JSON.parse(text) } catch { body = null }
858
+ return { status: Number(res.status), body, text, network: null, error: '' }
859
+ } catch (e) {
860
+ const msg = String((e && e.message) || e)
861
+ const timeout = (e && e.name === 'AbortError') || /abort/i.test(msg)
862
+ return {
863
+ status: null, body: null, text: '', network: timeout ? 'timeout' : 'unreachable',
864
+ error: timeout ? `请求超时(${timeoutMs}ms):${endpoint}` : `连不上 ${endpoint}:${msg}`,
865
+ }
866
+ } finally {
867
+ clearTimeout(timer)
868
+ }
869
+ }
870
+
871
+ /**
872
+ * 服务端响应 → 码记录(**形状校验也在陷阱守卫里**:返回 `status` 而不是 `state` ⇒ 响亮失败)。
873
+ * @returns {{ok:true, code:object} | {ok:false, code:number, reason:string, error:string}}
874
+ */
875
+ export function normalizeServerCode(body, { prefix = '', httpStatus = 0 } = {}) {
876
+ if (!body || typeof body !== 'object') {
877
+ return { ok: false, code: 1, reason: 'bad_output', error: `端点返回的响应不可解析(HTTP ${httpStatus},prefix ${prefix})` }
878
+ }
879
+ if (body.ok !== true) {
880
+ const reason = String(body.reason || '').trim()
881
+ if (httpStatus === 404 || reason === 'code_unknown') {
882
+ return { ok: true, code: { codePrefix: prefix, state: null, unknown: true, serverReason: reason || 'code_unknown' } }
883
+ }
884
+ return { ok: true, code: { codePrefix: prefix, state: null, error: reason || `HTTP ${httpStatus}` } }
885
+ }
886
+ if (!('state' in body)) {
887
+ const trap = 'status' in body
888
+ ? '响应里出现的是 `status` —— 码状态字段是 **`state`**(O2 陷阱①);按 `status` 读**恒空**,会把"有在途码"误读成"没有"'
889
+ : '响应里既没有 `state`(`ActivationCodeStatus.state` 是必填项)'
890
+ return {
891
+ ok: false, code: 2, reason: 'field_missing',
892
+ error: `GET ${CODE_STATUS_PATH}${prefix} 的响应缺 \`state\`:${trap}。⛔ 本视图不会把它当成"没有在途码"。`,
893
+ }
894
+ }
895
+ return {
896
+ ok: true,
897
+ code: {
898
+ codePrefix: body.codePrefix || prefix, state: body.state, intent: body.intent ?? null,
899
+ issuedAt: body.issuedAt ?? null, expiresAt: body.expiresAt ?? null, redeemedAt: body.redeemedAt ?? null,
900
+ failCount: num(body.failCount) || 0, lockedUntil: body.lockedUntil ?? null,
901
+ userId: body.issuedBy ?? null, deviceCodePrefix: body.deviceCodePrefix ?? null, licenseId: body.licenseId ?? null,
902
+ },
903
+ }
904
+ }
905
+
906
+ /**
907
+ * **一次只读取数的统一出口**(4 个新接口 + `code-status` 都走它):只发 GET(`httpGet` 里 method 硬编码),
908
+ * 并把"服务端**明确**说不行"的情形**响亮**映射成 top-level 失败(A2/A3:exit 2 + 明确 reason),
909
+ * 把"路由不在"(404 且响应体**不是**本服务信封)留给**节级** `endpoint_missing`(⛔ 不是空集)。
910
+ *
911
+ * 服务端契约(`whyshu-svr/docs/ops/license-ops-readonly.md` §0):
912
+ * · `405 + Allow: GET` = 动词不对(本客户端只发 GET ⇒ 见到它就说明链路不对)⇒ `method_not_allowed`(exit 2)
913
+ * · `400 field_unknown`(尤其 `?status=`)= 参数名不存在 ⇒ **绝不静默当空集** ⇒ `field_unknown`(exit 2)
914
+ * · `429` = 6 次/分限流 ⇒ `rate_limited`(exit 2,附"等 60 秒"的处置)
915
+ * · `200 {ok:false, reason:"field_missing"}` = 行级必需字段缺席 ⇒ `field_missing`(exit 2,回显 missingFields)
916
+ *
917
+ * @returns {Promise<{ok:true, body:object, status:number} | {ok:false, kind:string, exit:number, reason:string, error:string, httpStatus:number|null, body:object|null, missingFields?:string[]}>}
918
+ */
919
+ export async function opsServerGet({ id, relPath, baseUrl, token, timeoutMs, fetchImpl, observed = null }) {
920
+ const url = endpointOf(baseUrl, relPath)
921
+ const r = await httpGet({ endpoint: url, token, timeoutMs, fetchImpl })
922
+ const body = r.body && typeof r.body === 'object' ? r.body : null
923
+ const enveloped = Boolean(body && typeof body.reason === 'string')
924
+ if (observed && typeof observed.set === 'function') observed.set(id, { httpStatus: r.status, body, url, network: r.network })
925
+ const tag = `GET ${url}`
926
+ if (r.network) {
927
+ return {
928
+ ok: false, kind: r.network, httpStatus: null, body: null, exit: 1,
929
+ reason: r.network === 'timeout' ? 'activation_timeout' : 'activation_server_unreachable',
930
+ error: `${r.error}(服务端只读接口是**唯一**的远端数据源 ⇒ 读不到就没有结论,⛔ 不降级成"没有在途码")`,
931
+ }
932
+ }
933
+ const s = Number(r.status)
934
+ if (s === 401 || s === 403) {
935
+ return {
936
+ ok: false, kind: 'admin_required', httpStatus: s, body, exit: 2, reason: 'admin_required',
937
+ error: `${tag} 被拒:HTTP ${s}(服务端契约:\`adminAuth\` —— \`Bearer\` 令牌 + 该账号必须是管理员)。`
938
+ + '⇒ 换一个管理员 JWT(`--token-file <600 的文件>`),⛔ 本视图不会用别的端点绕过鉴权。',
939
+ }
940
+ }
941
+ if (s === 405) {
942
+ return {
943
+ ok: false, kind: 'method_not_allowed', httpStatus: s, body, exit: 2, reason: 'method_not_allowed',
944
+ error: `${tag} 回了 **405**(服务端契约:这些只读端点**只接受 GET**,其它动词一律 405 + \`Allow: GET\`)。`
945
+ + '本客户端**只会发 GET** ⇒ 405 说明链路/网关改写了动词或指错了端点,⛔ 不当作"没有数据"。',
946
+ }
947
+ }
948
+ if (s === 429) {
949
+ return {
950
+ ok: false, kind: 'rate_limited', httpStatus: s, body, exit: 2, reason: 'rate_limited',
951
+ error: `${tag} 回了 **429**(服务端契约:同一管理员 **6 次/分**)。这是**限流**、不是"没有数据":`
952
+ + '等 60 秒后重跑(⛔ 本视图不会自动重试、更不会降级成 0 条)。',
953
+ }
954
+ }
955
+ if (s === 404 && !enveloped) {
956
+ return {
957
+ ok: false, kind: 'route_missing', httpStatus: s, body, exit: 2, reason: 'endpoint_missing',
958
+ error: `${tag} ⇒ 404 且响应体**不是**本服务的信封(\`{ok,reason,…}\`)⇒ **路由不在**(接口未部署 / base URL 指错)。`
959
+ + '⛔ 这不是"空集":那一节会标 `available:false + endpoint_missing`。',
960
+ }
961
+ }
962
+ if (s !== 200 && !enveloped) {
963
+ return {
964
+ ok: false, kind: 'http_error', httpStatus: s, body, exit: 1, reason: 'bad_output',
965
+ error: `${tag} ⇒ HTTP ${s}(不是 200/404/401/405/429,且响应体不是本服务的信封)⇒ 无法判定这一节的内容,⛔ 不降级成 0 条。`,
966
+ }
967
+ }
968
+ if (!body) {
969
+ return {
970
+ ok: false, kind: 'bad_output', httpStatus: s, body: null, exit: 1, reason: 'bad_output',
971
+ error: `${tag} 的响应不是 JSON 对象(HTTP ${s})⇒ 无法判定这一节的内容。`,
972
+ }
973
+ }
974
+ // ⚠️ 服务端的业务性拒绝**可能挂在 400 上**(`?status=` ⇒ 400 `field_unknown`;分页越界 ⇒ 400 `bad_limit`),
975
+ // 也可能挂在 200 上(`field_missing`)⇒ 判定**只看 `ok` 与 `reason`**,⛔ 不看状态码(服务端契约 §0)。
976
+ if (body.ok !== true) {
977
+ const srvReason = String(body.reason || '').trim()
978
+ const missingFields = Array.isArray(body.missingFields) ? body.missingFields.map(String) : []
979
+ const hintOf = () => missingFields
980
+ .map((m) => OPS_FIELD_HINTS[OPS_SERVER_FIELD_HINTS[m] || String(m).split('.').pop()])
981
+ .filter(Boolean).slice(0, 3)
982
+ if (srvReason === 'field_missing') {
983
+ return {
984
+ ok: false, kind: 'field_missing', httpStatus: s, body, exit: 2, reason: 'field_missing', missingFields,
985
+ error: `${tag} ⇒ HTTP ${s} + \`ok:false, reason:"field_missing"\`(服务端契约 §0:**判定只看 \`ok\`**,⛔ 不看状态码)。`
986
+ + (missingFields.length ? `\n 服务端点名的缺字段:${missingFields.join('、')}` : '')
987
+ + (hintOf().length ? `\n ${hintOf().join('\n ')}` : '')
988
+ + '\n ⛔ 本视图**不会**把"字段不在"降级成"计数为 0",也**不会**替你筛掉坏行(那正是假绿)。',
989
+ }
990
+ }
991
+ if (srvReason === 'field_unknown') {
992
+ const field = String(body.field || '').trim()
993
+ const isStatusTrap = field === 'status' || field === ''
994
+ return {
995
+ ok: false, kind: 'field_unknown', httpStatus: s, body, exit: 2, reason: 'field_unknown',
996
+ error: `${tag} ⇒ HTTP ${s} \`field_unknown\`:服务端**不认识**参数 ${field ? `\`${field}\`` : '(未点名)'}。`
997
+ + (isStatusTrap ? `\n ⚠️ O2 陷阱①:码状态字段是 **\`state\`**,**不是 \`status\`** —— 服务端对 \`?status=\` 就是回这个码`
998
+ + '(⛔ 它**不会**静默按 `status` 筛出一页空,本视图也不把这种响应当"没有在途码")。' : '')
999
+ + (body.error ? `\n 服务端原文:${String(body.error).slice(0, 300)}` : ''),
1000
+ }
1001
+ }
1002
+ if (/^bad_/.test(srvReason)) {
1003
+ return {
1004
+ ok: false, kind: 'bad_request', httpStatus: s, body, exit: 2, reason: 'bad_request',
1005
+ error: `${tag} ⇒ 服务端拒绝了参数(\`reason:"${srvReason}"\`):${body.error ? String(body.error).slice(0, 300) : ''}`
1006
+ + '\n ⛔ 分页/取值越界不是"没有数据"(服务端契约:`limit` 上限 200、超限 400 而**不静默截断**)。',
1007
+ }
1008
+ }
1009
+ return {
1010
+ ok: false, kind: 'server_refused', httpStatus: s, body, exit: 1, reason: 'bad_output',
1011
+ error: `${tag} ⇒ HTTP ${s} 但 \`ok:false\`(reason=${JSON.stringify(srvReason || null)})⇒ 无法判定这一节的内容。`
1012
+ + (body.error ? `\n 服务端原文:${String(body.error).slice(0, 300)}` : ''),
1013
+ }
1014
+ }
1015
+ return { ok: true, body, status: s }
1016
+ }
1017
+
1018
+ /**
1019
+ * 探测用的占位值:带 `{prefix}`/`{codePrefix}` 的按占位符替换;以 `/` 结尾的路径(`activation-code/`、
1020
+ * `devices/`)直接**追加** 8 位占位前缀。⚠️ 两条路径的服务端语义不同且都算"在位":
1021
+ * `activation-code/ZZZZZZZZ` ⇒ 404 + 本服务信封(码不存在);`devices/ZZZZZZZZ` ⇒ 400 `bad_device`(非 hex)。
1022
+ */
1023
+ const probePlaceholder = (rel) => {
1024
+ let p = String(rel || '')
1025
+ if (p.includes('{prefix}')) return p.replace('{prefix}', 'ZZZZZZZZ')
1026
+ if (p.includes('{codePrefix}')) return p.replace('{codePrefix}', 'ZZZZZZZZ')
1027
+ if (p.endsWith('/')) p += 'ZZZZZZZZ'
1028
+ return p
1029
+ }
1030
+
1031
+ /**
1032
+ * `--probe`:**逐个实测**读端点(写端点一律不探)—— 给"在不在"提供实测证据,并标出与登记表是否一致。
1033
+ * 两种证据**都算实测**:`via:"live-call"`(本轮取数已经打过它,用那次的状态码/响应体判定)与
1034
+ * `via:"explicit"`(占位参数真探一次)。⇒ `--probe` 不会白烧限流额度(4 个数据接口本来就要打)。
1035
+ * ⛔ 404 + 响应体不是本服务信封 ⇒ `missing`;429 ⇒ **`unknown`**(限流钩子在路由之前 ⇒ 它证明不了路由在不在)。
1036
+ */
1037
+ export async function probeReadEndpoints({ baseUrl, token, timeoutMs, fetchImpl, endpoints = OPS_READ_ENDPOINTS, observed = null }) {
1038
+ const out = []
1039
+ for (const ep of endpoints) {
1040
+ const seen = observed && typeof observed.get === 'function' ? observed.get(ep.id) : null
1041
+ let obs = seen
1042
+ let via = 'live-call'
1043
+ if (!obs) {
1044
+ // ⛔ 只探 GET;URL 里带占位前缀(服务端按"码不存在"/"参数不合法"回 404/400 —— 与"路由不存在"用响应体区分)
1045
+ const url = endpointOf(baseUrl, probePlaceholder(ep.rel || ep.path.replace(/^\//, '')))
1046
+ const r = await httpGet({ endpoint: url, token, timeoutMs, fetchImpl })
1047
+ obs = { httpStatus: r.status, body: r.body && typeof r.body === 'object' ? r.body : null, url, network: r.network }
1048
+ via = 'explicit'
1049
+ }
1050
+ const enveloped = Boolean(obs.body && typeof obs.body.reason === 'string')
1051
+ const verdict = obs.network ? `探不到(${obs.network})`
1052
+ : obs.httpStatus === 200 ? (obs.body && obs.body.ok === false
1053
+ ? `exists(但响应 ok:false/${obs.body.reason} ⇒ 数据面有缺口)` : 'exists')
1054
+ : (obs.httpStatus === 401 || obs.httpStatus === 403) ? 'exists(需 admin 鉴权)'
1055
+ : obs.httpStatus === 405 ? 'exists(路由在,但 GET 被拒 ⇒ 405)'
1056
+ : obs.httpStatus === 400 ? (enveloped ? `exists(路由在,参数被拒:${obs.body.reason})` : 'unknown(400 且非本服务信封)')
1057
+ : obs.httpStatus === 404 ? (enveloped ? 'exists(路由在,资源不存在)' : 'missing(路由不在)')
1058
+ : obs.httpStatus === 429 ? 'unknown(429 限流命中,路由在不在判不了)'
1059
+ : `unknown(HTTP ${obs.httpStatus})`
1060
+ const live = verdict.startsWith('exists') ? 'exists' : verdict.startsWith('missing') ? 'missing' : 'unknown'
1061
+ const registered = ep.exists ? 'exists' : 'missing'
1062
+ out.push({
1063
+ id: ep.id, method: ep.method, path: ep.path, probed: true, via, httpStatus: obs.httpStatus ?? null,
1064
+ registered, verdict, live,
1065
+ agrees: live === 'unknown' ? null : live === registered,
1066
+ })
1067
+ }
1068
+ return out
1069
+ }
1070
+
1071
+ // ── 服务端节的**元信息**(宿主可判"这一页是不是全量")─────────────────────────────
1072
+ /**
1073
+ * 服务端分页元信息(映射④的落点):`total` = 分页前总数、`count` = 本页条数、`hasMore` = 还有更多。
1074
+ * ⚠️ `total`/`count` 的**语义由服务端定义**(`events.total` = 窗口内总数;`codes.total` = 命中的状态集合总数)
1075
+ * ⇒ 客户端**原样透传**并另给 `coverage`(`hasMore:true` ⇒ `page-only`),⛔ 不把它当"在途码数"用。
1076
+ */
1077
+ export function serverPageMeta({ endpoint, query = null, body = {}, states = null, limit = null }) {
1078
+ const b = body && typeof body === 'object' ? body : {}
1079
+ const items = Array.isArray(b.items) ? b.items : null
1080
+ return {
1081
+ endpoint,
1082
+ ...(query ? { query } : {}),
1083
+ ...(states ? { states } : {}),
1084
+ count: num(b.count) ?? (items ? items.length : null),
1085
+ total: num(b.total),
1086
+ limit: num(b.limit) ?? limit,
1087
+ offset: num(b.offset) ?? 0,
1088
+ hasMore: b.hasMore === true,
1089
+ }
1090
+ }
1091
+
1092
+ /** 异常清单:并入"服务端口径类"异常并重算 count(⛔ 只增不改判;判定仍在 `analyzeOps`)。 */
1093
+ export function mergeAnomalies(section, extra = []) {
1094
+ const items = [...((section && section.items) || []), ...extra]
1095
+ return { ...(section || { available: true }), count: items.length, items }
1096
+ }
1097
+
1098
+ /**
1099
+ * **顶层覆盖率**(`coverage`):`'full'` ⇔ 每个可用节都是 `coverage:'full'` 且**没有** `available:false` 的节。
1100
+ * `'partial'` = 至少答了一节但没全绿;`detail` 逐节给出 `coverage` 或 `unavailable:<reason>`(宿主可分支)。
1101
+ * ⚠️ `local` 那一节是**附注**(本机许可/审计),不参与覆盖口径。
1102
+ */
1103
+ export function summarizeCoverage(sections = {}) {
1104
+ const detail = {}
1105
+ let available = 0
1106
+ let full = 0
1107
+ for (const [k, s] of Object.entries(sections)) {
1108
+ if (k === 'local' || !s || typeof s !== 'object') continue
1109
+ if (s.available === false) { detail[k] = `unavailable:${s.reason || 'unknown'}`; continue }
1110
+ detail[k] = s.coverage || 'unknown'
1111
+ available += 1
1112
+ if ((s.coverage || '') === 'full') full += 1
1113
+ }
1114
+ const coverage = available > 0 && full === available ? 'full' : (available > 0 ? 'partial' : 'none')
1115
+ return { coverage, coverageDetail: detail, coverageSections: { available, full } }
1116
+ }
1117
+
1118
+ // ── verb 入口 ────────────────────────────────────────────────────────────────
1119
+ /**
1120
+ * `argent license ops [--from|--server] …`。
1121
+ * @param {string[]} argv 子命令参数(不含 `--json`;那是 CLI 层开关)
1122
+ * @param {{env?:object, dataDir?:string, repo?:string, fetch?:Function, now?:number, parsed?:object}} [deps]
1123
+ * @returns {Promise<{json:object, human:string, stderr:string, exit:number}>}
1124
+ */
1125
+ export async function runOpsVerb(argv = [], deps = {}) {
1126
+ const env = deps.env || process.env
1127
+ const p = deps.parsed || parseOpsArgs(argv)
1128
+ if (!p.ok) return failJson(p.code, p.reason, p.error, { usage: OPS_USAGE })
1129
+ const { values, repeat, bools } = p
1130
+ const asJson = deps.asJson === true || bools.has('--json') || argv.includes('--json')
1131
+ if (bools.has('--help') || bools.has('-h')) {
1132
+ return { json: { ok: true, empty: false, code: 0, reason: null, subcommand: 'ops', usage: OPS_USAGE }, human: OPS_USAGE, stderr: '', exit: 0 }
1133
+ }
1134
+
1135
+ const dataDir = deps.dataDir || resolveDataDir({ env }).dir
1136
+ const now = deps.now ?? (values['--now'] ? parseTime(values['--now']) : Date.now())
1137
+ if (!Number.isFinite(now)) {
1138
+ return failJson(2, 'usage_error', `--now 不是可解析的时间:${values['--now']}(用 ISO 8601,如 2026-10-04T12:00:00Z)`)
1139
+ }
1140
+ let days = OPS_DEFAULT_DAYS
1141
+ if (values['--days'] !== undefined) {
1142
+ const d = num(values['--days'])
1143
+ if (d === null || !Number.isInteger(d) || d <= 0 || d > 365) {
1144
+ return failJson(2, 'usage_error', `--days 只接受 1..365 的整数,实得 ${JSON.stringify(values['--days'])}`)
1145
+ }
1146
+ days = d
1147
+ }
1148
+
1149
+ // `--filter`:字段必须在**本域词表**里 —— 否则 `field_missing`(陷阱①的入口)
1150
+ const filters = []
1151
+ for (const raw of repeat['--filter'] || []) {
1152
+ const i = String(raw).indexOf('=')
1153
+ if (i <= 0) return failJson(2, 'usage_error', `--filter 需要 <字段=值> 形态,实得 ${JSON.stringify(raw)}(可用字段:${OPS_FILTER_FIELDS.join('、')})`)
1154
+ const field = String(raw).slice(0, i).trim()
1155
+ const value = String(raw).slice(i + 1)
1156
+ if (!OPS_FILTER_FIELDS.includes(field)) {
1157
+ return failJson(2, 'field_missing',
1158
+ `--filter 用了本域不存在的字段 \`${field}\`(可用:${OPS_FILTER_FIELDS.join('、')})。`
1159
+ + (OPS_FIELD_HINTS[field] ? `\n ${OPS_FIELD_HINTS[field]}` : '')
1160
+ + '\n ⛔ 本视图**不会**把"字段不存在"变成"筛出 0 条"(那正是假绿)。')
1161
+ }
1162
+ filters.push({ field, value })
1163
+ }
1164
+
1165
+ // `--prefix`:形态即服务端契约(8 位 Crockford base32)
1166
+ const prefixes = []
1167
+ for (const raw of repeat['--prefix'] || []) {
1168
+ const s = String(raw).trim().toUpperCase()
1169
+ if (!/^[0-9A-HJKMNP-TV-Z]{8}$/.test(s)) {
1170
+ return failJson(2, 'usage_error', '--prefix 必须是 **8 位** Crockford base32'
1171
+ + '(`0123456789ABCDEFGHJKMNPQRSTVWXYZ`,没有 I/L/O/U;连字符可有可无),'
1172
+ + `实得 ${JSON.stringify(raw)}`)
1173
+ }
1174
+ prefixes.push(s)
1175
+ }
1176
+ const deviceQuery = values['--device'] ? String(values['--device']).trim().toLowerCase() : ''
1177
+ if (deviceQuery && !/^[0-9a-f]{8,64}$/.test(deviceQuery)) {
1178
+ return failJson(2, 'usage_error', '--device 只接受 8..64 位十六进制(12 位前缀或 64hex 全码;⛔ 不接受主机名/MAC/IP)')
1179
+ }
1180
+
1181
+ // 数据源互斥
1182
+ const srcCount = [values['--from'], values['--server'], bools.has('--local') ? 'local' : null].filter(Boolean).length
1183
+ if (srcCount > 1) {
1184
+ return failJson(2, 'usage_error', '`--from` / `--server` / `--local` 只能给一个(三种数据源互斥 —— 混用时"这个数是从哪来的"说不清)')
1185
+ }
1186
+
1187
+ // ── 分支 1:离线快照 ──────────────────────────────────────────────────────
1188
+ if (values['--from']) {
1189
+ const snap = loadOpsSnapshot(values['--from'])
1190
+ if (!snap.ok) return failJson(snap.code, snap.reason, snap.error)
1191
+ const snapNow = values['--now'] ? now : (parseTime(snap.data.generatedAt) ?? now)
1192
+ const fields = checkOpsFields({
1193
+ codes: snap.data.codes || [], events: snap.data.events || [], accounts: snap.data.accounts || [],
1194
+ devices: (snap.data.accounts || []).flatMap((a) => (a && Array.isArray(a.devices) ? a.devices : [])),
1195
+ })
1196
+ if (!fields.ok) return failJson(2, 'field_missing', fields.hint, { fieldProfile: fields.profile, missingFields: fields.missing })
1197
+ const a = analyzeOps(snap.data, { now: snapNow, days, filters, device: deviceQuery })
1198
+ // ⚠️ **集合缺席 ≠ 集合为空**:快照里根本没有 `accounts` 键 ⇒ 席位账目那一节标 `no_data`(附要哪个集合),
1199
+ // ⛔ 不报"0 个账号"(那与"真没有账号"分不开,正是本视图要防的假绿形态)。
1200
+ const has = (k) => Object.prototype.hasOwnProperty.call(snap.data, k)
1201
+ const sections = {
1202
+ inflight: has('codes') ? a.inflight
1203
+ : unavailable('no_data', '快照里的 `codes` 集合', '这份快照没有码记录 ⇒ 在途码(与失败码)答不了(⛔ 不是"没有在途码")'),
1204
+ failures: has('codes') || has('events') ? a.failures
1205
+ : unavailable('no_data', '快照里的 `codes`/`events` 集合', '失败码 = failCount>0 或 revoked 的码 + 拒绝事件计数,两者都不在快照里'),
1206
+ timeline: has('events') ? a.timeline
1207
+ : unavailable('no_data', '快照里的 `events` 集合', '没有事件集合 ⇒ 近 N 天时间线答不了'),
1208
+ seats: has('accounts') ? a.seats
1209
+ : unavailable('no_data', '快照里的 `accounts` 集合', `席位账目要账号 + 设备记录;${SEAT_DEFINITION}`),
1210
+ anomalies: (has('codes') || has('events') || has('accounts')) ? a.anomalies
1211
+ : unavailable('no_data', '快照里的 `codes`/`events`/`accounts` 集合', '三类记录都不在快照里 ⇒ 一条异常也判不了'),
1212
+ ...(a.device ? { device: a.device } : {}),
1213
+ }
1214
+ return envelope({
1215
+ a, mode: 'snapshot', dataDir, now: snapNow, days, asJson, sectionsObj: sections, empty: computeEmpty(sections),
1216
+ source: { kind: 'snapshot', from: snap.path, generatedAt: snap.data.generatedAt ?? null, note: snap.data.source ?? null },
1217
+ fieldProfile: fields.profile,
1218
+ missingEndpoints: [], probe: null, prefixes: null, prefixSource: null, local: null,
1219
+ })
1220
+ }
1221
+
1222
+ // ── 分支 2:服务端只读接口 ────────────────────────────────────────────────
1223
+ if (values['--server']) {
1224
+ const server = resolveActivationServer({ flag: values['--server'], env })
1225
+ if (!server.ok) return failJson(2, server.reason, server.error, { server: { source: server.source || null } })
1226
+ const tok = resolveOpsToken({ flag: values['--token-file'] || null, env, dataDir })
1227
+ if (!tok.ok) return failJson(2, tok.reason, tok.error, { token: { source: tok.source || null, file: tok.file || null, mode: tok.mode === null || tok.mode === undefined ? null : `0${tok.mode.toString(8)}` } })
1228
+ const fetchImpl = deps.fetch || globalThis.fetch
1229
+ if (typeof fetchImpl !== 'function') return failJson(1, 'activation_server_unreachable', '本运行时没有 fetch 实现(Node ≥18 才有)⇒ 无法调服务端只读接口')
1230
+ const timeoutMs = resolveTimeoutMs(env)
1231
+ const local = readLocalOps({ env, dataDir, now })
1232
+
1233
+ // ── 只读取数(**只发 GET**):4 个服务端只读接口 + 显式 `--prefix` 的单查 ──────────────
1234
+ // · 404 且响应体**不是**本服务信封 ⇒ **路由不在**(接口未部署 / base URL 指错)⇒ 那一节标 endpoint_missing,
1235
+ // ⛔ **不是空集**(这正是"接口已就绪但还没部署"这个真实窗口要防的假绿);
1236
+ // · 其余异常(401/403/405/429/400/200-ok:false/连不上)⇒ **响亮失败**(exit 2/1 + 明确 reason),
1237
+ // ⛔ 绝不降级成"0 条"。
1238
+ const observed = new Map() // endpoint id → 本轮**实测**到的状态码/响应体(`--probe` 复用)
1239
+ const routeMissing = new Map() // endpoint id → 实测"路由不在"的证据串
1240
+ const get = (id, relPath) => opsServerGet({ id, relPath, baseUrl: server.baseUrl, token: tok.token, timeoutMs, fetchImpl, observed })
1241
+ const epOf = (id) => OPS_READ_ENDPOINTS.find((e) => e.id === id)
1242
+ const fatal = (res, id) => failJson(res.exit, res.reason, res.error, {
1243
+ server: { baseUrl: server.baseUrl, baseUrlSource: server.source, tls: server.tls, endpoint: id },
1244
+ ...(res.missingFields ? { missingFields: res.missingFields } : {}),
1245
+ })
1246
+
1247
+ const sinceMs = now - days * 86400000
1248
+ const codesQuery = `state=${OPS_CODES_QUERY_STATES.join(',')}&limit=${OPS_PAGE_LIMIT}&offset=0`
1249
+ const eventsQuery = `since=${encodeURIComponent(iso(sinceMs))}&limit=${OPS_PAGE_LIMIT}&offset=0`
1250
+ const seatsQuery = `limit=${OPS_PAGE_LIMIT}&offset=0`
1251
+
1252
+ const codesRes = await get('codes-list', `${CODES_LIST_REL}?${codesQuery}`)
1253
+ if (!codesRes.ok && codesRes.kind !== 'route_missing') return fatal(codesRes, 'codes-list')
1254
+ if (!codesRes.ok) routeMissing.set('codes-list', codesRes.error)
1255
+ const eventsRes = await get('events', `${EVENTS_REL}?${eventsQuery}`)
1256
+ if (!eventsRes.ok && eventsRes.kind !== 'route_missing') return fatal(eventsRes, 'events')
1257
+ if (!eventsRes.ok) routeMissing.set('events', eventsRes.error)
1258
+ const seatsRes = await get('seats', `${SEATS_REL}?${seatsQuery}`)
1259
+ if (!seatsRes.ok && seatsRes.kind !== 'route_missing') return fatal(seatsRes, 'seats')
1260
+ if (!seatsRes.ok) routeMissing.set('seats', seatsRes.error)
1261
+ const deviceRes = deviceQuery ? await get('device-lookup', `${DEVICE_LOOKUP_REL}${deviceQuery}`) : null
1262
+ if (deviceRes && !deviceRes.ok && deviceRes.kind !== 'route_missing') return fatal(deviceRes, 'device-lookup')
1263
+ if (deviceRes && !deviceRes.ok) routeMissing.set('device-lookup', deviceRes.error)
1264
+
1265
+ const codesOk = codesRes.ok
1266
+ const eventsOk = eventsRes.ok
1267
+ const seatsOk = seatsRes.ok
1268
+ const deviceOk = Boolean(deviceRes && deviceRes.ok)
1269
+
1270
+ // 前缀:**只查显式 `--prefix`**(含 code-status 里已终态的码);codes-list 在位时不再拿本机审计的前缀去凑数
1271
+ const prefixSource = prefixes.length ? 'flag:--prefix'
1272
+ : codesOk ? 'none(codes-list 已在位 ⇒ 不需要按前缀凑数)'
1273
+ : (local.prefixes.length ? 'local-audit(codes-list 不在位 ⇒ 退回本机激活审计见过的前缀)' : 'none')
1274
+ const wantPrefixes = prefixes.length ? prefixes : (codesOk ? [] : local.prefixes.slice(0, 20))
1275
+ const prefixCodes = []
1276
+ let prefixRouteMissing = false
1277
+ for (const pre of wantPrefixes) {
1278
+ const url = endpointOf(server.baseUrl, `${CODE_STATUS_PATH}${pre}`)
1279
+ const r = await httpGet({ endpoint: url, token: tok.token, timeoutMs, fetchImpl })
1280
+ observed.set('code-status', { httpStatus: r.status, body: r.body && typeof r.body === 'object' ? r.body : null, url, network: r.network })
1281
+ if (r.network) {
1282
+ return failJson(1, r.network === 'timeout' ? 'activation_timeout' : 'activation_server_unreachable',
1283
+ `${r.error}(服务端只读接口是**唯一**的远端数据源 ⇒ 读不到就没有结论,⛔ 不降级成"没有在途码")`,
1284
+ { server: { baseUrl: server.baseUrl, endpoint: server.endpoint, source: server.source, tls: server.tls } })
1285
+ }
1286
+ if (r.status === 404 && !(r.body && typeof r.body.reason === 'string')) {
1287
+ prefixRouteMissing = true
1288
+ routeMissing.set('code-status', `GET ${url} ⇒ 404 且响应体**不是**本服务信封 ⇒ 路由不在`)
1289
+ break
1290
+ }
1291
+ const norm = normalizeServerCode(r.body, { prefix: pre, httpStatus: r.status })
1292
+ if (!norm.ok) return failJson(norm.code, norm.reason, norm.error, { server: { baseUrl: server.baseUrl }, prefix: pre })
1293
+ prefixCodes.push({ ...norm.code, httpStatus: r.status })
1294
+ }
1295
+
1296
+ // ── 服务端行 → 客户端形状(**四处映射陷阱的唯一落点**;⛔ 只白名单投影,不整行透传)────────
1297
+ const rawCodes = codesOk ? codesRes.body.items : []
1298
+ const rawEvents = eventsOk ? eventsRes.body.items : []
1299
+ const rawAccounts = seatsOk ? seatsRes.body.items : []
1300
+ const rawDevices = deviceOk && Array.isArray(deviceRes.body.items) ? deviceRes.body.items : []
1301
+ // 行级字段契约(服务端口径的镜像;⛔ 空集合不算缺字段)
1302
+ const fieldCheck = checkServerRows({
1303
+ codes: Array.isArray(rawCodes) ? rawCodes : [],
1304
+ events: Array.isArray(rawEvents) ? rawEvents : [],
1305
+ accounts: Array.isArray(rawAccounts) ? rawAccounts : [],
1306
+ devices: rawDevices,
1307
+ })
1308
+ if (!fieldCheck.ok) return failJson(2, 'field_missing', fieldCheck.hint, { missingFields: fieldCheck.missing })
1309
+
1310
+ const codes = [...(codesOk ? mapServerCodes(rawCodes) : []), ...prefixCodes.filter((c) => c.state)]
1311
+ .reduce((m, c) => (c && c.codePrefix ? m.set(String(c.codePrefix).toUpperCase(), c) : m), new Map())
1312
+ const codeRows = [...codes.values()]
1313
+ const events = eventsOk ? mapServerEvents(rawEvents) : []
1314
+ const accounts = seatsOk ? mapServerSeats(rawAccounts) : []
1315
+
1316
+ // 一次 analyzeOps:五节与异常清单**只有这一处判据**(与快照/本机模式同源)
1317
+ const a = analyzeOps({ codes: codeRows, events, accounts }, { now, days, filters, device: deviceQuery })
1318
+
1319
+ // ── 五节(服务端口径;每节带 `server` 元信息 + `coverage`,宿主可判"这一页是不是全量")──────
1320
+ const sections = {}
1321
+ const prefixUsable = wantPrefixes.length > 0 && !prefixRouteMissing
1322
+
1323
+ // ① 在途码
1324
+ if (codesOk) {
1325
+ const hasMore = codesRes.body.hasMore === true
1326
+ sections.inflight = {
1327
+ ...a.inflight,
1328
+ coverage: hasMore ? 'page-only' : 'full',
1329
+ server: serverPageMeta({ endpoint: epOf('codes-list').path, query: codesQuery, body: codesRes.body, states: OPS_CODES_QUERY_STATES }),
1330
+ ...(hasMore ? { note: `服务端还有更多行(total ${num(codesRes.body.total)} > 本页 ${a.inflight.count})⇒ 这一节是"最新一页",⛔ 不是全量` } : {}),
1331
+ }
1332
+ } else if (prefixUsable) {
1333
+ sections.inflight = {
1334
+ ...a.inflight, coverage: 'prefix-only', prefixes: wantPrefixes, prefixSource,
1335
+ note: `GET ${epOf('codes-list').path} 不在位(路由 404)⇒ 退回按前缀单查:只有 ${wantPrefixes.length} 个前缀被查过`
1336
+ + `(来源:${prefixSource})—— ⛔ 这不是"全部在途码"`,
1337
+ }
1338
+ } else {
1339
+ sections.inflight = unavailable('endpoint_missing', epOf('codes-list').path,
1340
+ `全量在途码要 GET ${epOf('codes-list').path}(本轮实测 404 路由不在 ⇒ 接口未部署或 base URL 指错)`
1341
+ + `${wantPrefixes.length ? ',且按前缀单查那条路也不在位' : ';也没有可用前缀可退'}(⛔ 不是"没有在途码")`)
1342
+ }
1343
+
1344
+ // ② 失败码(`failCount>0`/`revoked` 的码 + `*_denied` 事件计数)
1345
+ if (codesOk || eventsOk) {
1346
+ const sources = [codesOk ? 'codes' : null, eventsOk ? 'events' : null].filter(Boolean)
1347
+ sections.failures = {
1348
+ ...a.failures,
1349
+ coverage: codesOk && eventsOk ? 'full' : 'partial',
1350
+ server: {
1351
+ sources,
1352
+ ...(codesOk ? { codes: serverPageMeta({ endpoint: epOf('codes-list').path, query: codesQuery, body: codesRes.body }) } : {}),
1353
+ ...(eventsOk ? { events: serverPageMeta({ endpoint: epOf('events').path, query: eventsQuery, body: eventsRes.body }) } : {}),
1354
+ },
1355
+ ...(codesOk && eventsOk ? {} : { note: `只有 ${sources.join('+')} 在位 ⇒ 失败码口径不完整(⛔ 不是"全量")` }),
1356
+ }
1357
+ if (prefixUsable) sections.failures.partial = a.failures
1358
+ } else if (prefixUsable) {
1359
+ sections.failures = {
1360
+ ...unavailable('endpoint_missing', epOf('events').path,
1361
+ '失败码分布与拒绝事件计数都要读 `licenseevents`;按前缀单查只能看 `failCount`/`lockedUntil` 两个字段'),
1362
+ partial: a.failures,
1363
+ }
1364
+ } else {
1365
+ sections.failures = unavailable('endpoint_missing', `${epOf('codes-list').path} + ${epOf('events').path}`,
1366
+ '失败码 = `failCount>0`/`revoked` 的码 + `*_denied` 事件计数;两个数据源都不在位(⛔ 不是"没有失败码")')
1367
+ }
1368
+
1369
+ // ③ 时间线(近 N 天;服务端 `total` = 窗口内总数、`count` = 本页 —— 映射④)
1370
+ if (eventsOk) {
1371
+ const hasMore = eventsRes.body.hasMore === true
1372
+ sections.timeline = {
1373
+ ...a.timeline,
1374
+ coverage: hasMore ? 'page-only' : 'full',
1375
+ server: serverPageMeta({ endpoint: epOf('events').path, query: eventsQuery, body: eventsRes.body }),
1376
+ ...(hasMore ? { note: `服务端只回窗口内**最新** ${OPS_PAGE_LIMIT} 条(total ${num(eventsRes.body.total)})⇒ 更早的没进这一页` } : {}),
1377
+ }
1378
+ } else {
1379
+ sections.timeline = unavailable('endpoint_missing', epOf('events').path,
1380
+ `近 N 天事件时间线要读 \`licenseevents\` 的只读投影(${epOf('events').path};本轮实测 404 路由不在 ⇒ 接口未部署)`
1381
+ + '(⛔ 不是"这 N 天没有事件")')
1382
+ }
1383
+
1384
+ // ④ 席位账目(映射②:`items/totals` → `accounts/seatTotals`;`used` 客户端**自己算**并比对)
1385
+ let totalsMismatch = null
1386
+ if (seatsOk) {
1387
+ const hasMore = seatsRes.body.hasMore === true
1388
+ const serverTotals = seatsRes.body.totals && typeof seatsRes.body.totals === 'object' ? seatsRes.body.totals : null
1389
+ const mine = a.seats.totals
1390
+ if (serverTotals) {
1391
+ const keys = ['accounts', 'seats', 'deviceLimit', 'used', 'free']
1392
+ const diff = keys.filter((k) => num(serverTotals[k]) !== null && num(serverTotals[k]) !== mine[k])
1393
+ if (diff.length) {
1394
+ totalsMismatch = {
1395
+ id: 'seat_totals_mismatch', severity: 'warn', subject: 'seats.totals',
1396
+ detail: `席位合计与服务端不一致(字段:${diff.join('、')}):服务端 ${JSON.stringify(serverTotals)}`
1397
+ + ` vs 本视图按设备记录重算 ${JSON.stringify(mine)}(已释放的 \`releasedAt\` 非空)记录**不占**席位)`,
1398
+ needs: `核对设备记录与服务端口径;${SEAT_DEFINITION}`,
1399
+ }
1400
+ }
1401
+ }
1402
+ sections.seats = {
1403
+ ...a.seats,
1404
+ coverage: hasMore ? 'page-only' : 'full',
1405
+ server: {
1406
+ ...serverPageMeta({ endpoint: epOf('seats').path, query: seatsQuery, body: seatsRes.body }),
1407
+ ...(serverTotals ? { totals: serverTotals } : {}),
1408
+ ...(seatsRes.body.definition ? { definition: seatsRes.body.definition } : {}),
1409
+ },
1410
+ totalsMatch: totalsMismatch === null,
1411
+ note: '`principals[].used` / `totals` 是**本视图按 `releasedAt` 为空的设备码去重**算出来的(映射②/陷阱②);'
1412
+ + '`server.totals` 是服务端自己的口径,两边不一致会记一条 `seat_used_mismatch`/`seat_totals_mismatch`。',
1413
+ }
1414
+ } else {
1415
+ sections.seats = unavailable('endpoint_missing', epOf('seats').path,
1416
+ `席位账目要服务端按 \`releasedAt\` 为空的设备码去重算 \`used\` 再回(${epOf('seats').path};`
1417
+ + `本轮实测 404 路由不在 ⇒ 接口未部署)(⛔ 不是"没有账号/没有席位")。${SEAT_DEFINITION}`)
1418
+ }
1419
+
1420
+ // ⑤ 异常清单(只看不判;席位口径不一致的两类在这里并入)
1421
+ const anomalySources = [codesOk, eventsOk, seatsOk, prefixUsable].filter(Boolean).length
1422
+ sections.anomalies = anomalySources
1423
+ ? {
1424
+ ...mergeAnomalies(a.anomalies, totalsMismatch ? [totalsMismatch] : []),
1425
+ coverage: codesOk && eventsOk && seatsOk ? 'full' : 'partial',
1426
+ ...(codesOk && eventsOk && seatsOk ? {} : { note: '部分只读接口不在位 ⇒ 只有拿到的那些记录参与判定(⛔ 不是"没有异常")' }),
1427
+ }
1428
+ : unavailable('endpoint_missing', `${epOf('codes-list').path} + ${epOf('events').path} + ${epOf('seats').path}`,
1429
+ '三类记录(码/事件/席位)都没读到 ⇒ 一条异常也判不了(⛔ 不是"没有异常")')
1430
+
1431
+ // ⑥ 设备归属(映射③:命中处数看服务端 **`total`**)
1432
+ if (deviceQuery) {
1433
+ if (deviceOk) {
1434
+ const dev = mapServerDevice(deviceRes.body, { query: deviceQuery })
1435
+ sections.device = { ...dev, server: { ...dev.server, query: deviceQuery } }
1436
+ } else {
1437
+ sections.device = unavailable('endpoint_missing', `${epOf('device-lookup').path}{codePrefix}`,
1438
+ '设备归属要服务端按设备码查绑定记录'
1439
+ + `(${epOf('device-lookup').path}{codePrefix};本轮实测 404 路由不在 ⇒ 接口未部署)(⛔ 不是"这台设备没挂过账号")`)
1440
+ }
1441
+ }
1442
+ sections.local = { ...local.license, activations: local.activations }
1443
+
1444
+ // 缺接口清单 = 静态登记 `exists:false` ∪ **本轮实测 404**("已就绪但未部署"就是这么被抓出来的)
1445
+ const missingEndpoints = [
1446
+ ...OPS_READ_ENDPOINTS.filter((e) => !e.exists)
1447
+ .map((e) => ({ id: e.id, method: e.method, path: e.path, neededFor: e.neededFor, why: e.note })),
1448
+ ...[...routeMissing.entries()].map(([id, error]) => {
1449
+ const e = epOf(id)
1450
+ return {
1451
+ id, method: e.method, path: e.path, neededFor: e.neededFor, why: e.note,
1452
+ runtime: true, httpStatus: 404, error,
1453
+ }
1454
+ }),
1455
+ ]
1456
+
1457
+ const probe = bools.has('--probe')
1458
+ ? await probeReadEndpoints({ baseUrl: server.baseUrl, token: tok.token, timeoutMs, fetchImpl, observed })
1459
+ : null
1460
+ const empty = computeEmpty(sections)
1461
+ return envelope({
1462
+ a: null, mode: 'server', dataDir, now, days, asJson,
1463
+ source: {
1464
+ kind: 'server', baseUrl: server.baseUrl, baseUrlSource: server.source, tls: server.tls, loopback: server.loopback,
1465
+ endpoints: OPS_READ_ENDPOINTS.map((e) => `${e.method} ${e.path}`),
1466
+ timeoutMs, pageLimit: OPS_PAGE_LIMIT,
1467
+ tokenSource: tok.source, tokenFile: tok.file, token: '(已配置,⛔ 不回显)',
1468
+ },
1469
+ sectionsObj: sections, missingEndpoints, probe, empty, prefixSource, prefixes: wantPrefixes,
1470
+ observed, local, fieldProfile: null,
1471
+ })
1472
+ }
1473
+
1474
+ // ── 分支 3(默认):本机只读产物 ──────────────────────────────────────────
1475
+ const local = readLocalOps({ env, dataDir, now })
1476
+ const localAnomalies = []
1477
+ const rejected = local.activations.rows.filter((r) => r.ok === false)
1478
+ if (rejected.length) {
1479
+ localAnomalies.push({
1480
+ id: 'local_activation_rejected', severity: 'warn', subject: 'license-activation/audit.jsonl',
1481
+ detail: `本机激活审计里有 ${rejected.length} 条失败兑换(最近:${rejected.slice(-3).map((r) => `${r.codePrefix || '?'}/${r.reason || '?'}`).join('、')})`,
1482
+ needs: '按前缀走 `argent license ops --server <base> --prefix <前缀>` 查服务端状态(或 `argent license activate` 的重试话术)',
1483
+ })
1484
+ }
1485
+ const missingEndpoints = OPS_READ_ENDPOINTS.filter((e) => !e.exists).map((e) => ({ id: e.id, method: e.method, path: e.path, neededFor: e.neededFor, why: e.note }))
1486
+ const sections = {
1487
+ inflight: unavailable('needs_server', OPS_READ_ENDPOINTS[1].path, '本机产物里没有别人的码;在途码要服务端只读接口(`--server <base>`)'),
1488
+ failures: unavailable('needs_server', OPS_READ_ENDPOINTS[2].path, '失败码分布要读 `licenseevents`(本机只有自己那几次兑换的审计)'),
1489
+ timeline: unavailable('needs_server', OPS_READ_ENDPOINTS[2].path, '近 N 天事件时间线要读 `licenseevents`'),
1490
+ seats: unavailable('needs_server', OPS_READ_ENDPOINTS[3].path, `席位账目(含全部账号)要服务端;本机只能看自己这一份许可声明的席位。${SEAT_DEFINITION}`),
1491
+ anomalies: localAnomalies.length
1492
+ ? { available: true, coverage: 'local-only', count: localAnomalies.length, items: localAnomalies }
1493
+ : { available: true, coverage: 'local-only', count: 0, items: [], note: '本机审计里没有失败兑换;服务端侧异常需要上面缺的接口' },
1494
+ local: { ...local.license, activations: local.activations },
1495
+ }
1496
+ const empty = computeEmpty(sections)
1497
+ return envelope({
1498
+ a: null, mode: 'local', dataDir, now, days, asJson,
1499
+ source: { kind: 'local', dataDir, note: '本机只读产物:许可声明 + 激活审计(零网络、零副作用)' },
1500
+ sectionsObj: sections, missingEndpoints, probe: null, empty, prefixSource: null, prefixes: local.prefixes,
1501
+ local, fieldProfile: null,
1502
+ })
1503
+ }
1504
+
1505
+ /** 统一信封(A2:`ok/empty/code/reason` 恒在;`$? == json.code`)。 */
1506
+ function envelope({ a, mode, dataDir, now, days, asJson, source, sectionsObj = null, missingEndpoints, probe, empty, prefixSource, prefixes, local = null, fieldProfile, observed = null }) {
1507
+ void asJson
1508
+ const sections = sectionsObj || {
1509
+ inflight: a.inflight, failures: a.failures, timeline: a.timeline, seats: a.seats, anomalies: a.anomalies,
1510
+ ...(a.device ? { device: a.device } : {}),
1511
+ }
1512
+ const unavailableList = Object.entries(sections)
1513
+ .filter(([, s]) => s && s.available === false)
1514
+ .map(([k, s]) => ({ section: k, reason: s.reason, needs: s.needs }))
1515
+ // 顶层覆盖率(宿主一条字段就能判"这份视图是不是全的");⛔ 与逐节 `coverage` 同源,不另立判据
1516
+ const cov = summarizeCoverage(sections)
1517
+ const json = {
1518
+ ok: true,
1519
+ empty: empty === true,
1520
+ code: 0,
1521
+ reason: empty === true ? 'no_match' : null,
1522
+ subcommand: 'ops',
1523
+ readOnly: true,
1524
+ sideEffects: 'none',
1525
+ mode,
1526
+ source,
1527
+ dataDir,
1528
+ now: iso(now),
1529
+ days,
1530
+ coverage: cov.coverage,
1531
+ coverageDetail: cov.coverageDetail,
1532
+ ...(prefixSource ? { prefixSource } : {}),
1533
+ ...(prefixes ? { prefixes } : {}),
1534
+ ...(fieldProfile ? { fieldProfile } : {}),
1535
+ ...sections,
1536
+ ...(a ? { filters: a.filters, since: a.since } : {}),
1537
+ ...(probe ? { probe } : {}),
1538
+ missingEndpoints,
1539
+ unavailable: unavailableList,
1540
+ ...(local ? { local } : {}),
1541
+ definition: { seat: SEAT_DEFINITION, ttlGraceMinutes: OPS_TTL_GRACE_MINUTES, deniedRepeat: OPS_DENIED_REPEAT, inflightStates: OPS_INFLIGHT_STATES },
1542
+ warnings: [
1543
+ '只读视图:⛔ 不写任何集合/文件/审计;HTTP 只发 GET;⛔ 绝不调 `POST /api/argent/license/release-device`'
1544
+ + '(该端点的 200 成功路径在生产库**从未跑过** ⇒ 视图不代触发写)。',
1545
+ ...(mode === 'local' || mode === 'server'
1546
+ ? ['缺席的小节是 `available:false`(附 `needs`),**不是 0** —— 这正是 O2 两个陷阱(`state` 不是 `status`、席位是算出来的)要防的假绿。']
1547
+ : []),
1548
+ ...(mode === 'server' && (missingEndpoints || []).some((e) => e.runtime)
1549
+ ? ['服务端有只读接口**实测 404(路由不在)**:接口已就绪但**还没部署** ⇒ 相关小节标 `endpoint_missing`,⛔ 不是"0 条"。']
1550
+ : []),
1551
+ ],
1552
+ endpoints: OPS_READ_ENDPOINTS.map((e) => {
1553
+ const obs = observed && typeof observed.get === 'function' ? observed.get(e.id) : null
1554
+ return {
1555
+ id: e.id, method: e.method, path: e.path, exists: e.exists, neededFor: e.neededFor,
1556
+ ...(obs ? { observed: { httpStatus: obs.httpStatus ?? null, network: obs.network || null } } : {}),
1557
+ }
1558
+ }),
1559
+ judgedBy: 'core/lib/license-ops.mjs(只读;分析在 analyzeOps,纯函数)',
1560
+ }
1561
+ // A1:本 verb 只有两个出口 —— `0`(成功,含"成功但空")与失败信封(2 用法/环境、1 数据源读失败)。
1562
+ return { json, human: humanOps(json), stderr: '', exit: 0 }
1563
+ }
1564
+
1565
+ /** 人读形态(一眼能看出"哪节缺"与"为什么缺")。 */
1566
+ export function humanOps(j) {
1567
+ const L = []
1568
+ const mark = j.empty ? 'ℹ️' : '🔎'
1569
+ L.push(`${mark} 许可运营巡检(只读)—— 数据源 ${j.mode} | 现在 ${j.now} | 窗口 ${j.days} 天`)
1570
+ if (j.mode === 'snapshot') L.push(` 快照:${j.source.from}${j.source.generatedAt ? `(生成于 ${j.source.generatedAt})` : ''}`)
1571
+ if (j.mode === 'server') L.push(` 服务端:${j.source.baseUrl}(TLS ${j.source.tls ? '是' : '否,仅回环'})| token 来源 ${j.source.tokenSource}`)
1572
+ if (j.mode === 'local') L.push(` 数据目录:${j.dataDir}`)
1573
+
1574
+ const sec = (k) => j[k] || {}
1575
+ const inf = sec('inflight')
1576
+ if (inf.available === false) L.push(` ⛔ 在途码:**答不了** —— ${inf.reason}(需要 ${inf.needs})`)
1577
+ else {
1578
+ L.push(` 在途码 ${inf.count} 个(覆盖 ${inf.coverage})${inf.coverage === 'prefix-only' ? `(仅已查的 ${(inf.prefixes || []).length} 个前缀 —— ⛔ 不是全量)` : ''}`)
1579
+ for (const c of inf.codes || []) {
1580
+ const tail = c.overdueMinutes > 0 ? ` ⚠️ **超 TTL ${c.overdueMinutes} 分钟未终态**` : (c.ttlLeftMinutes !== null ? ` TTL 剩 ${c.ttlLeftMinutes} 分` : '')
1581
+ L.push(` · ${c.codePrefix} ${c.state}${c.intent ? `/${c.intent}` : ''}${c.failCount ? ` failCount ${c.failCount}` : ''}${tail}`)
1582
+ }
1583
+ }
1584
+ const fal = sec('failures')
1585
+ if (fal.available === false) L.push(` ⛔ 失败码:**答不了** —— ${fal.reason}(需要 ${fal.needs})`)
1586
+ else {
1587
+ const reasons = Object.entries(fal.byReason || {}).map(([k, v]) => `${k} ${v}`).join(' | ') || '无'
1588
+ L.push(` 失败码 ${fal.count} 个(覆盖 ${fal.coverage};${Object.entries(fal.byState || {}).map(([k, v]) => `${k} ${v}`).join('、') || '无'})| 拒绝事件:${reasons}`)
1589
+ for (const c of fal.codes || []) L.push(` · ${c.codePrefix} ${c.state} failCount ${c.failCount}`)
1590
+ }
1591
+ const tl = sec('timeline')
1592
+ if (tl.available === false) L.push(` ⛔ 事件时间线:**答不了** —— ${tl.reason}(需要 ${tl.needs})`)
1593
+ else {
1594
+ const win = tl.server && tl.server.total !== null && tl.server.total !== undefined
1595
+ ? `(服务端窗口内 total ${tl.server.total},本页 ${tl.count}${tl.server.hasMore ? ',hasMore ⇒ 更早的没进这一页' : ''})` : ''
1596
+ L.push(` 近 ${tl.days} 天事件 ${tl.count} 条${win}(覆盖 ${tl.coverage}):${Object.entries(tl.counts || {}).map(([k, v]) => `${k} ${v}`).join(' | ') || '无'}`)
1597
+ for (const e of (tl.events || []).slice(-8)) L.push(` · ${e.at} ${e.action}/${e.result}${e.codePrefix ? ` ${e.codePrefix}` : ''}`)
1598
+ if ((tl.events || []).length > 8) L.push(` …(另有 ${tl.events.length - 8} 条,--json 看全量)`)
1599
+ }
1600
+ const st = sec('seats')
1601
+ if (st.available === false) L.push(` ⛔ 席位账目:**答不了** —— ${st.reason}(需要 ${st.needs})`)
1602
+ else {
1603
+ L.push(` 席位账目(覆盖 ${st.coverage};${st.totals.accounts} 个账号:seats ${st.totals.seats} | 上限 ${st.totals.deviceLimit} | 已用 ${st.totals.used} | 余 ${st.totals.free})`
1604
+ + `${st.totalsMatch === false ? ' ⚠️ 与服务端 totals 不一致(见异常 seat_totals_mismatch)' : ''}`)
1605
+ for (const a of st.principals || []) {
1606
+ L.push(` · ${a.userId} seats ${a.seats} deviceLimit ${a.deviceLimit}(${a.deviceLimitSource}) 已用 ${a.used} 余 ${a.free}`)
1607
+ }
1608
+ L.push(` 口径:${st.definition}`)
1609
+ }
1610
+ const an = sec('anomalies')
1611
+ if (an.available === false) L.push(` ⛔ 异常清单:**答不了** —— ${an.reason}(需要 ${an.needs})`)
1612
+ else if ((an.count || 0) === 0) L.push(` 异常:0 条${an.note ? `(${an.note})` : ''}`)
1613
+ else {
1614
+ L.push(` 异常 ${an.count} 条:`)
1615
+ for (const it of an.items) L.push(` ${it.severity === 'alert' ? '🚨' : '⚠️'} ${it.id} ${it.subject}:${it.detail}\n ↳ ${it.needs}`)
1616
+ }
1617
+ if (j.device) {
1618
+ const d = j.device
1619
+ if (d.available === false) L.push(` ⛔ 设备归属(${d.query || ''}):**答不了** —— ${d.reason}(需要 ${d.needs})`)
1620
+ else L.push(` 设备归属 ${d.query}…:命中 ${d.found} 处(= 服务端 \`total\`;本页 ${d.count} 条${d.hasMore ? ',hasMore' : ''})`
1621
+ + `(账号 ${d.accounts.map((x) => x.userId).join('、') || '无'}${d.accounts.some((x) => x.devices.some((v) => v.releasedAt)) ? ';含已释放的绑定' : ''})`)
1622
+ }
1623
+ if (j.local) {
1624
+ const lic = j.local.license || {}
1625
+ L.push(` 本机许可:${lic.state}${lic.present ? ` | 账号 ${lic.userId || '(未写)'} | 席位 ${lic.seats} | 到期 ${lic.expiresAt || '-'}` : '(没有许可文件)'}`)
1626
+ const act = j.local.activations || { count: 0 }
1627
+ L.push(` 本机激活审计:${act.exists ? `${act.count} 行(${act.file})` : '无(这台机器没兑换过)'}`)
1628
+ }
1629
+ if (j.coverage) L.push(` 📊 覆盖率:**${j.coverage}**(${Object.entries(j.coverageDetail || {}).map(([k, v]) => `${k}:${v}`).join(' | ')})`)
1630
+ if (j.mode === 'server' && j.source && j.source.token) L.push(` 🔑 token:${j.source.token}(来源 ${j.source.tokenSource})`)
1631
+ if ((j.missingEndpoints || []).length) {
1632
+ L.push(` ⛔ 缺的服务端**只读**接口 ${j.missingEndpoints.length} 个(答不了的部分已标 available:false,**没有用 0 充数**):`)
1633
+ for (const e of j.missingEndpoints) {
1634
+ L.push(` · ${e.method} ${e.path}${e.runtime ? `\n ↳ **本轮实测 404(路由不在)**:接口已就绪但还没部署 / base URL 指错` : `\n ↳ ${e.why}`}`)
1635
+ }
1636
+ }
1637
+ if (j.probe) {
1638
+ L.push(' 🔍 端点探测(**实测**:只发 GET;写端点一律不探;`live-call` = 本轮取数那次,`explicit` = 占位真探一次):')
1639
+ for (const p of j.probe) {
1640
+ L.push(` · ${p.method} ${p.path} ⇒ ${p.verdict}(登记 ${p.registered}${p.probed ? ` | 证据 ${p.via}${p.httpStatus === null ? '' : ` HTTP ${p.httpStatus}`}` : ''}`
1641
+ + `${p.agrees === false ? ' | ⚠️ **与登记表不一致**' : ''})`)
1642
+ }
1643
+ }
1644
+ return L.join('\n')
1645
+ }