@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,873 @@
1
+ #!/usr/bin/env python3
2
+ """遥测上报(日志上报)—— 客户机侧:**元数据事件**的采集、脱敏、排队、上报。
3
+
4
+ ## 设计口径(2026-09-21 用户裁定;2026-10-04 合规改造 = 形态 A「告知—同意」)
5
+ - **默认关(opt-in)**:`config.json#telemetryOptIn` **无键 = 不开**。开启只有两条路 ——
6
+ ① 安装期在**明确告知字段范围**后由用户点同意(`install-argent.mjs` 的「诊断上报(可选)」步);
7
+ ② 设置页「诊断上报」开关。`TELEMETRY_DISABLE=1` 是**硬关**(优先级最高)。
8
+ ⛔ **没有任何环境变量能"打开"它** —— 打开必须在 `config.json` 留下显式同意记录。
9
+ - **不补发**:关闭期间**不采集、不堆积、不补发**。队列按**采集时刻的同意标记**过滤
10
+ (`consent='optin-v1'`):升级前("默认开"时代)产生的事件**不带标记** ⇒ 被剔除、永不外发;
11
+ 且关闭状态下 `flush` 会直接清空本地队列。**开启期间**产生的失败事件,退避重试语义不变。
12
+ - **只上报元数据**:哪台机器(`instanceId` + 主机名**哈希** `hostHash`)、哪一版
13
+ (version/能力包版本)、哪条流程、卡在哪一步(phase)、什么性质(errorClass)、耗时/尝试次数。
14
+ **店铺名 / SKU / 货件号 / 截图 / 日志全文 / 原始主机名一律不上报** —— 主防线在这里(服务端
15
+ `additionalProperties:false` 只是兜底,而且是"静默剥离"不是报错)。
16
+ - **归属由服务端决定**:客户端**不自报客户名**。服务端从登录令牌解出 `userId`
17
+ (令牌取自 `~/.dsh/argent/config.json` 的 `apiKey`),所以客户端伪造不了归属。
18
+ - **绝不阻断主流程**:任何失败都静默(写 `state.json` 的失败计数),调用方拿到的永远是 0。
19
+ - **不联网也照跑**:事件先落本地 outbox(追加写 + flock),由触发链每轮 `--flush` 一次;
20
+ 断网/服务端不可用时按退避重试,outbox 有容量上限(超了丢最旧并计数)。
21
+ - **透明**:`--preview` 能打印"将要发什么";`--status` 打印队列与最近结果。
22
+
23
+ ## 幂等
24
+ 每条事件带 `eventId`(uuid)。上报成功才把它移出 outbox;若"发出去了但没收到响应",
25
+ 下次重试带**同一个 eventId**,服务端按 (userId, eventId) 去重 ⇒ 不会产生重复行。
26
+
27
+ 用法:
28
+ python3 scripts/telemetry.py --init-instance
29
+ python3 scripts/telemetry.py --emit --kind failure --capability invoice \\
30
+ --flow wenyao-invoice --phase P4验收 --error-class needs_agent --case wenyao-invoice__76636923 \\
31
+ --detail '等待选择器超时 #label'
32
+ python3 scripts/telemetry.py --heartbeat --flush
33
+ python3 scripts/telemetry.py --flush [--max 20]
34
+ python3 scripts/telemetry.py --preview
35
+ python3 scripts/telemetry.py --status
36
+ python3 scripts/telemetry.py --purge # 清空本地队列(关闭上报/撤回同意时用)
37
+ 环境变量:ARGENT_DATA_DIR(数据目录)、WHYSHU_API_URL(上报地址覆盖,测试用)、
38
+ TELEMETRY_DISABLE=1(**硬关**;注意:没有任何环境变量能"打开"上报)
39
+ 退出码:恒 0(除显式用法错误 2);本模块不允许因为遥测失败而影响调用方。
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import base64
45
+ import datetime as dt
46
+ import hashlib
47
+ import json
48
+ import os
49
+ import pathlib
50
+ import platform
51
+ import re
52
+ import socket
53
+ import sys
54
+ import time
55
+ import urllib.error
56
+ import urllib.request
57
+ import uuid
58
+
59
+ HERE = pathlib.Path(__file__).resolve().parent
60
+ sys.path.insert(0, str(HERE / 'lib'))
61
+ from argent_datadir import resolve_data_dir # noqa: E402 数据目录唯一判据(勿在此复制链)
62
+ # 跨平台文件锁**唯一真源**(POSIX `fcntl.flock` / Windows `msvcrt.locking`):
63
+ # 此前本文件顶层 `import fcntl` ⇒ Windows 上 `ModuleNotFoundError` ⇒ 遥测整个不可用。
64
+ from argent_filelock import best_effort # noqa: E402
65
+
66
+ try:
67
+ from flow_registry import consumer_doc as _registry_doc
68
+ except Exception: # noqa: BLE001 —— 半坏机器上退回单文件
69
+ def _registry_doc(path=None):
70
+ _p = pathlib.Path(path) if path else pathlib.Path(__file__).resolve().with_name("flow-requirements.json")
71
+ return json.loads(_p.read_text(encoding="utf-8"))
72
+
73
+ REPO = HERE.parent
74
+
75
+ # ── 路径与上限 ────────────────────────────────────────────────────────────────
76
+ OUTBOX_MAX_EVENTS = 1000
77
+ OUTBOX_MAX_BYTES = 5 * 1024 * 1024
78
+ DETAIL_MAX = 2000 # 与 服务端 TELEMETRY_DETAIL_MAX 同口径
79
+ SENT_RING = 500 # 已发 id 的环形记录(仅用于自检/去重观察)
80
+ HTTP_TIMEOUT = 8
81
+ BACKOFF_BASE_SEC = 300 # 5 分钟起
82
+ BACKOFF_MAX_SEC = 6 * 3600 # 最长 6 小时
83
+ GONE_CODES = (401, 402, 404, 405, 501) # 未登录 / 未订阅 / 端点尚未部署 ⇒ 长退避,不刷屏
84
+ EVENT_KINDS = ('heartbeat', 'failure', 'engine')
85
+ ENDPOINT = '/api/argent/telemetry/event'
86
+ # ── 同意标记(合规形态 A:告知—同意)──────────────────────────────────────────
87
+ # 每条事件在**采集时刻**打上标记,`flush` 只发带标记的。作用有两个:
88
+ # ① 迁移语义:升级前("默认开"时代)产生、还压在 outbox 里的事件**不带标记** ⇒ 被剔除、永不外发
89
+ # (客户在"关闭/未同意"状态下产生的东西,不能在开启后补发出去);
90
+ # ② 撤回语义:标记是采集时刻的事实,之后的开关变化不会把旧事件"洗白"。
91
+ # ⚠️ 这个键**只在本地**:`_post()` 会把它从 HTTP 载荷里剔除(服务端字段契约一个字都不变)。
92
+ CONSENT_TAG = 'optin-v1'
93
+ HOST_HASH_LEN = 12
94
+
95
+
96
+ def data_dir() -> pathlib.Path:
97
+ """数据目录:**唯一判据**在 `scripts/lib/argent_datadir.py`(三级链,与 JS 内核同语义)。"""
98
+ return pathlib.Path(resolve_data_dir()['dir'])
99
+
100
+
101
+ def tel_dir() -> pathlib.Path:
102
+ return data_dir() / 'telemetry'
103
+
104
+
105
+ def outbox_path() -> pathlib.Path:
106
+ return tel_dir() / 'outbox.jsonl'
107
+
108
+
109
+ def state_path() -> pathlib.Path:
110
+ return tel_dir() / 'state.json'
111
+
112
+
113
+ def sent_path() -> pathlib.Path:
114
+ return tel_dir() / 'sent-ids.jsonl'
115
+
116
+
117
+ def instance_path() -> pathlib.Path:
118
+ return data_dir() / 'instance.json'
119
+
120
+
121
+ def api_base() -> str:
122
+ raw = os.environ.get('WHYSHU_API_URL') or ''
123
+ if raw:
124
+ return raw.rstrip('/')
125
+ try:
126
+ cfg = json.loads((data_dir() / 'config.json').read_text(encoding='utf-8'))
127
+ base = str(cfg.get('apiBase') or '').strip()
128
+ if base:
129
+ return base.rstrip('/')
130
+ except Exception: # noqa: BLE001
131
+ pass
132
+ return 'https://whyshu.com'
133
+
134
+
135
+ def auth_token() -> str:
136
+ """登录令牌:当前配置面 config.json 的 apiKey(服务端用它解出 userId)。"""
137
+ token = os.environ.get('WHYSHU_API_KEY') or ''
138
+ if token:
139
+ return token.strip()
140
+ try:
141
+ cfg = json.loads((data_dir() / 'config.json').read_text(encoding='utf-8'))
142
+ return str(cfg.get('apiKey') or '').strip()
143
+ except Exception: # noqa: BLE001
144
+ return ''
145
+
146
+
147
+ # ── 凭证:**实例令牌优先,账号令牌兜底**(2026-10-05 路线 B「机器自带凭证」)──────
148
+ # 背景:客户机是"一直开着、没人登录"的,账号 JWT 只有 30 天且在服务端轮换密钥时全灭
149
+ # (2026-09-14 实况)⇒ 交付一台机器时应交付**随机器走**的凭证。
150
+ # 形态与契约(唯一权威 = whyshu-svr/docs/ops/instance-token.md):
151
+ # 文件 <数据目录>/instance-token.json(0600)—— 安装器从许可同一条离线下发链路写入;
152
+ # 头 x-instance-token: <紧凑串 it1.<b64url(payload)>.<sig)>(与服务端 middleware/instanceAuth.js 同口径)
153
+ # 加固 x-instance-device: <64hex 设备码>(取自本机 license.json#device.code;服务端比对令牌里的 deviceCode)
154
+ # ⛔ **fail-closed**:没有令牌 ⇒ 不发(`no-token`),**绝不**匿名上报、绝不崩。
155
+ # ⛔ 令牌串**只进 HTTP 头**,不进日志、不进 state.json、`--status` 只显示"来源类型"。
156
+ INSTANCE_TOKEN_FILE = 'instance-token.json'
157
+ INSTANCE_TOKEN_HEADER = 'x-instance-token'
158
+ INSTANCE_DEVICE_HEADER = 'x-instance-device'
159
+ INSTANCE_TOKEN_ENV = 'ARGENT_INSTANCE_TOKEN'
160
+
161
+
162
+ def instance_token_path() -> pathlib.Path:
163
+ return data_dir() / INSTANCE_TOKEN_FILE
164
+
165
+
166
+ def _decode_token_payload(token: str) -> dict:
167
+ """紧凑串 → 载荷(**只解码、不验签**:客户端没有签名密钥,判真伪是服务端的事)。
168
+
169
+ 解码只为两件事:① 本地 fail-closed 自检(instanceId 是否是本机的);② `--status` 回显
170
+ 到期日与来源类型。任何异常 ⇒ 空 dict(绝不因为"看不懂令牌"而抛出去)。
171
+ """
172
+ try:
173
+ parts = str(token or '').split('.')
174
+ if len(parts) != 3:
175
+ return {}
176
+ pad = '=' * (-len(parts[1]) % 4)
177
+ raw = base64.urlsafe_b64decode(parts[1] + pad)
178
+ obj = json.loads(raw.decode('utf-8'))
179
+ return obj if isinstance(obj, dict) else {}
180
+ except Exception: # noqa: BLE001
181
+ return {}
182
+
183
+
184
+ def local_device_code() -> str:
185
+ """本机设备码:取自已安装的 `license.json#device.code`(64hex)。
186
+
187
+ 为什么从许可文件取而不是自己跑 `ioreg`:那个值**就是签发侧用的那个**(令牌与许可一起下发),
188
+ 两边必然一致;自己再算一遍只会在算法/夹具上引入新的分叉点。
189
+ 读不到 ⇒ 空串(服务端对"没带设备头"按加固层放行,见中间件注释)。
190
+ """
191
+ try:
192
+ lic = json.loads((data_dir() / 'license.json').read_text(encoding='utf-8'))
193
+ code = str(((lic or {}).get('device') or {}).get('code') or '').strip().lower()
194
+ return code if re.fullmatch(r'[0-9a-f]{64}', code) else ''
195
+ except Exception: # noqa: BLE001
196
+ return ''
197
+
198
+
199
+ def instance_token() -> dict:
200
+ """读实例令牌。**只读一个键**(`token`):文件允许带便于人工核对的元数据,但不参与鉴权。
201
+
202
+ 返回 `{token, source, payload, instanceId, deviceCode, exp, error}`;没有 ⇒ token 为空。
203
+ 优先级:环境变量 `ARGENT_INSTANCE_TOKEN`(运维/测试覆盖)→ `<数据目录>/instance-token.json`。
204
+ """
205
+ out = {'token': '', 'source': '', 'payload': {}, 'instanceId': '', 'deviceCode': '', 'exp': '', 'error': ''}
206
+ raw = (os.environ.get(INSTANCE_TOKEN_ENV) or '').strip()
207
+ src = f'env:{INSTANCE_TOKEN_ENV}'
208
+ if not raw:
209
+ p = instance_token_path()
210
+ try:
211
+ raw = p.read_text(encoding='utf-8').strip()
212
+ src = f'file:{p}'
213
+ except Exception: # noqa: BLE001
214
+ return out
215
+ if not raw:
216
+ return out
217
+ token = raw
218
+ if raw.startswith('{'): # JSON 信封(安装器写入的正常形态)
219
+ try:
220
+ doc = json.loads(raw)
221
+ token = str((doc or {}).get('token') or '').strip()
222
+ except Exception: # noqa: BLE001
223
+ out['error'] = 'unparsable-json'
224
+ return out
225
+ if not token:
226
+ out['error'] = 'empty-token'
227
+ return out
228
+ payload = _decode_token_payload(token)
229
+ if not payload:
230
+ out['error'] = 'undecodable-token'
231
+ out.update({'token': token, 'source': src, 'payload': payload,
232
+ 'instanceId': str(payload.get('instanceId') or ''),
233
+ 'deviceCode': str(payload.get('deviceCode') or '').lower(),
234
+ 'exp': str(payload.get('exp') or '')})
235
+ return out
236
+
237
+
238
+ def account_token() -> str:
239
+ """账号登录令牌(兜底来源):env `WHYSHU_API_KEY` → `config.json#apiKey`。"""
240
+ return auth_token()
241
+
242
+
243
+ def credential() -> dict:
244
+ """**当前生效的凭证**(唯一判定点):实例令牌优先,账号令牌兜底。
245
+
246
+ 返回 `{kind, token, source, headers, note}`:
247
+ · `kind='instance'` ⇒ 只发 `x-instance-token`(+ 可能的 `x-instance-device`);**不发** Bearer;
248
+ · `kind='account'` ⇒ 老路径 `Authorization: Bearer <账号 JWT>`(逐字节不变,向后兼容);
249
+ · `kind='none'` ⇒ fail-closed:不发(`no-token`)。
250
+ ⛔ 返回的 `token` 只许进 HTTP 头;调用方**不得**打印/落盘(`--status` 只看 kind/source)。
251
+ """
252
+ inst = instance_token()
253
+ if inst['token'] and not inst['error']:
254
+ # 本地自检:令牌是**发给这台机器**的吗?(instanceId 不匹配 ⇒ 绝不发网,本地就停,
255
+ # 否则会变成"服务端 403 + 长退避"的静默故障;故障点在这里更可诊断)
256
+ me = str(ensure_instance().get('installId') or '')
257
+ if inst['instanceId'] and me and inst['instanceId'] != me:
258
+ return {'kind': 'none', 'token': '', 'source': inst['source'], 'headers': {},
259
+ 'note': f'instance-token-mismatch(令牌是给 {inst["instanceId"]} 的,本机是 {me})'}
260
+ headers = {INSTANCE_TOKEN_HEADER: inst['token']}
261
+ dev = local_device_code() or inst['deviceCode']
262
+ if dev:
263
+ headers[INSTANCE_DEVICE_HEADER] = dev
264
+ return {'kind': 'instance', 'token': inst['token'], 'source': inst['source'],
265
+ 'headers': headers, 'note': ''}
266
+ acct = account_token()
267
+ if acct:
268
+ return {'kind': 'account', 'token': acct, 'source': 'account-jwt', 'headers': {},
269
+ 'note': ('instance-token 不可用(' + inst['error'] + ')⇒ 回落账号令牌'
270
+ if inst['error'] else '')}
271
+ return {'kind': 'none', 'token': '', 'source': inst['source'] if inst['error'] else '',
272
+ 'headers': {}, 'note': ('instance-token 不可用(' + inst['error'] + ')' if inst['error'] else '')}
273
+
274
+
275
+ def _config_opt_in() -> bool:
276
+ """读 `config.json#telemetryOptIn`:**显式为真才算开**(无键/缺文件/false/读坏 ⇒ 一律不开)。"""
277
+ try:
278
+ cfg = json.loads((data_dir() / 'config.json').read_text(encoding='utf-8'))
279
+ except Exception: # noqa: BLE001
280
+ return False
281
+ v = cfg.get('telemetryOptIn')
282
+ if v is True:
283
+ return True
284
+ return str(v).strip().lower() in ('true', '1', 'yes')
285
+
286
+
287
+ def telemetry_enabled() -> bool:
288
+ """**默认关(opt-in)**:只有 `config.json#telemetryOptIn` 显式为真才开。
289
+
290
+ 优先级:`TELEMETRY_DISABLE=1` ⇒ **硬关**(最高,压过一切)> `telemetryOptIn`。
291
+ ⛔ 没有任何环境变量能**打开**上报 —— 打开只有"留下显式同意记录"这一条路
292
+ (安装期的「诊断上报(可选)」步,或设置页的「诊断上报」开关)。
293
+ """
294
+ if os.environ.get('TELEMETRY_DISABLE') in ('1', 'true', 'yes'):
295
+ return False
296
+ return _config_opt_in()
297
+
298
+
299
+ def host_hash(host: str = '', salt: str = '') -> str:
300
+ """主机名 → **不可逆**短哈希(12 hex,同机同盐恒等 ⇒ 可去重、可分组)。
301
+
302
+ 为什么不是明文:原始 hostname 常含用户名/机器名(如 `huangwei-mac-mini.local`),是
303
+ 最容易指向具体个人/设备的字段。盐 = **本机随机值**(`instance.json#hostSalt`,0600),
304
+ **只存本地、从不出网** ⇒ 云端拿到哈希连字典攻击的对象都没有。
305
+ 代价(写清楚):**重装/换机会换盐 ⇒ 哈希会变**;跨安装去重请用 `instanceId`
306
+ (它本来就是"每安装唯一",语义正好)。
307
+ """
308
+ if not host:
309
+ return ''
310
+ return hashlib.sha256(f'{salt}|{host}'.encode('utf-8')).hexdigest()[:HOST_HASH_LEN]
311
+
312
+
313
+ # ── 机器身份(instance.json)──────────────────────────────────────────────────
314
+ def ensure_instance() -> dict:
315
+ """读取/创建实例身份。同一个客户两台机器、或同机两个 profile 都能分开。
316
+
317
+ ⚠️ 2026-10-04(合规):`host`(原始主机名)**只留本地**(本机自检/排障看),
318
+ 上报走 `hostHash` —— 盐 `hostSalt` 是**本机随机值、从不出网**,老实例会在首次读取时补种
319
+ (就地补齐,不重建 installId)。
320
+ """
321
+ p = instance_path()
322
+ d = None
323
+ try:
324
+ d = json.loads(p.read_text(encoding='utf-8'))
325
+ except Exception: # noqa: BLE001
326
+ d = None
327
+ if not isinstance(d, dict) or not d.get('installId'):
328
+ d = {
329
+ 'installId': uuid.uuid4().hex[:16],
330
+ 'createdAt': dt.datetime.now().isoformat(timespec='seconds'),
331
+ 'host': socket.gethostname()[:64],
332
+ 'os': platform.system().lower()[:32],
333
+ 'arch': platform.machine()[:32],
334
+ }
335
+ changed = False
336
+ if not d.get('hostSalt'):
337
+ d['hostSalt'] = uuid.uuid4().hex # 本机随机盐:**永不出网**(见 host_hash)
338
+ changed = True
339
+ host = str(d.get('host') or socket.gethostname())
340
+ if not d.get('hostHash') or d.get('hostHashSalt') != d.get('hostSalt'):
341
+ d['hostHash'] = host_hash(host, str(d.get('hostSalt') or ''))
342
+ d['hostHashSalt'] = d['hostSalt'] # 记录"这个哈希是哪个盐算的"(换盐必重算)
343
+ changed = True
344
+ if changed:
345
+ try:
346
+ p.parent.mkdir(parents=True, exist_ok=True)
347
+ p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
348
+ os.chmod(p, 0o600)
349
+ except Exception: # noqa: BLE001
350
+ pass
351
+ return d
352
+
353
+
354
+ _CAP_MAP: dict | None = None
355
+
356
+
357
+ def capability_of(flow: str) -> str:
358
+ """流程 → 能力(读注册表一次并缓存;读不到就空)。"""
359
+ global _CAP_MAP
360
+ if _CAP_MAP is None:
361
+ _CAP_MAP = {}
362
+ try:
363
+ doc = _registry_doc()
364
+ for fid, spec in (doc.get('flows') or {}).items():
365
+ _CAP_MAP[str(fid)] = str((spec or {}).get('capability') or '')
366
+ except Exception: # noqa: BLE001
367
+ pass
368
+ return _CAP_MAP.get(str(flow or ''), '')
369
+
370
+
371
+ def flow_of_case(case_id: str) -> str:
372
+ """票号 → 流程 id(caseId 形如 `<flow>__<票>`)。"""
373
+ cid = str(case_id or '')
374
+ return cid.split('__', 1)[0] if '__' in cid else ''
375
+
376
+
377
+ # 引擎级失败的"合成票号"(不是真实货物票):这类由**触发链**发 kind=engine,
378
+ # 台账钩子要跳过,否则同一次失败会产生两条事件(一条 failure + 一条 engine)。
379
+ ENGINE_CASE_PREFIXES = ('claim__', 'preflight__', 'invoice-ready-leftover__')
380
+
381
+
382
+ def is_engine_case(case_id: str) -> bool:
383
+ return str(case_id or '').startswith(ENGINE_CASE_PREFIXES)
384
+
385
+
386
+ def repo_version() -> str:
387
+ try:
388
+ return (REPO / 'VERSION').read_text(encoding='utf-8').strip()
389
+ except Exception: # noqa: BLE001
390
+ return ''
391
+
392
+
393
+ def pack_versions() -> dict:
394
+ """能力包版本(决定"他那版还有没有这个 bug")。读锁文件,读不到就空。"""
395
+ try:
396
+ lock = json.loads((HERE / 'capability-packs.lock.json').read_text(encoding='utf-8'))
397
+ return {k: str((v or {}).get('version') or '') for k, v in (lock.get('capabilities') or {}).items()}
398
+ except Exception: # noqa: BLE001
399
+ return {}
400
+
401
+
402
+ # ── 脱敏(**这是主防线**:日志/错误文本里什么都可能有)──────────────────────
403
+ _SENSITIVE = (
404
+ # 令牌/密钥类(与 orchestrator 的同口径,另加 URL 里的 query)
405
+ (re.compile(r'(?i)((?:tenant_|user_)?access_token|app_?secret|client_secret|password|passwd|'
406
+ r'authorization|cookie|api[_-]?key)("?\s*[:=]\s*"?)([A-Za-z0-9_\-\.]{6,})'), r'\1\2***'),
407
+ (re.compile(r'(?i)(--[a-z-]*(?:token|secret|key|pass)\s+)(\S+)'), r'\1***'),
408
+ (re.compile(r'(?i)\b(bearer|basic)\s+[A-Za-z0-9._\-+/=]{8,}'), r'\1 ***'),
409
+ # JWT(eyJ...)与常见的 40+ 长十六进制/Base64 串
410
+ (re.compile(r'\beyJ[A-Za-z0-9._\-]{20,}'), 'JWT***'),
411
+ (re.compile(r'\b[A-Fa-f0-9]{40,}\b'), 'HEX***'),
412
+ # 手机号 / 长数字(货件号、订单号、record_id 之类)
413
+ (re.compile(r'(?<!\d)1[3-9]\d{9}(?!\d)'), 'PHONE***'),
414
+ # 6 位起就掩(实测:货件号 76636923 是 8 位,用 10 位规则会漏)
415
+ (re.compile(r'(?<!\d)\d{6,}(?!\d)'), 'NUM***'),
416
+ # 邮箱 / URL query(query 里常带 token 或业务参数)
417
+ (re.compile(r'\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b'), 'MAIL***'),
418
+ (re.compile(r'(https?://[^\s?]+)\?[^\s]*'), r'\1?***'),
419
+ )
420
+
421
+
422
+ def _host_variants() -> list:
423
+ """本机主机名的**全部写法**(含短名/去 `.local`),用于把 detail 里的明文主机名掩掉。
424
+
425
+ 为什么要在 redact 里补这一条(2026-10-04 合规):字段面已经改成 `hostHash` 了,但
426
+ `detail` 是自由文本 —— 历史调用点就写过 `detail=f'host={socket.gethostname()}'`,
427
+ 类似形态(`--detail` 手敲、异常文本里带 host)随时会再出现 ⇒ **字段面 + 文本面**两道都要堵。
428
+ """
429
+ out = []
430
+ try:
431
+ h = socket.gethostname() or ''
432
+ except Exception: # noqa: BLE001
433
+ h = ''
434
+ for cand in (h, h.split('.')[0] if '.' in h else '', h[:-len('.local')] if h.endswith('.local') else ''):
435
+ c = str(cand or '').strip()
436
+ if len(c) >= 3 and c not in out:
437
+ out.append(c)
438
+ return out
439
+
440
+
441
+ _HOST_RE = None
442
+
443
+
444
+ def redact(text: str, cap: int = DETAIL_MAX) -> str:
445
+ """脱敏 + 截断。宁可少信息,也不把客户业务值/凭据/**主机名**带上去。"""
446
+ global _HOST_RE
447
+ out = str(text or '')
448
+ if _HOST_RE is None:
449
+ vs = _host_variants()
450
+ _HOST_RE = re.compile('|'.join(re.escape(v) for v in vs), re.I) if vs else False
451
+ if _HOST_RE:
452
+ out = _HOST_RE.sub('HOST***', out)
453
+ for pat, rep in _SENSITIVE:
454
+ out = pat.sub(rep, out)
455
+ out = out.replace('\n', ' ⏎ ')
456
+ if len(out) > cap:
457
+ out = out[:cap] + '…'
458
+ return out
459
+
460
+
461
+ def case_hash(case_id: str) -> str:
462
+ """票号只上报哈希:能关联同一张票的多次事件,又不把业务主键送到云端。"""
463
+ return hashlib.sha256(str(case_id or '').encode('utf-8')).hexdigest()[:12] if case_id else ''
464
+
465
+
466
+ # ── outbox / state ───────────────────────────────────────────────────────────
467
+ def _append_locked(path: pathlib.Path, line: str) -> None:
468
+ path.parent.mkdir(parents=True, exist_ok=True)
469
+ with open(path, 'a', encoding='utf-8') as f:
470
+ # outbox 追加写:策略 (b) **响亮降级**(本模块的契约是"绝不阻断主流程",
471
+ # 见模块 docstring;旧实现在 Windows 上静默无锁)。出声 = stderr 一行 + 进程内计数。
472
+ with best_effort(f, site='telemetry.append', note='outbox 追加降级为无锁写'):
473
+ f.write(line)
474
+ f.flush()
475
+ os.fsync(f.fileno())
476
+
477
+
478
+ def read_state() -> dict:
479
+ try:
480
+ return json.loads(state_path().read_text(encoding='utf-8'))
481
+ except Exception: # noqa: BLE001
482
+ return {}
483
+
484
+
485
+ def write_state(**kv) -> None:
486
+ st = read_state()
487
+ st.update(kv)
488
+ try:
489
+ state_path().parent.mkdir(parents=True, exist_ok=True)
490
+ state_path().write_text(json.dumps(st, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
491
+ except Exception: # noqa: BLE001
492
+ pass
493
+
494
+
495
+ def read_outbox() -> list:
496
+ out = []
497
+ p = outbox_path()
498
+ if not p.exists():
499
+ return out
500
+ for ln in p.read_text(encoding='utf-8', errors='replace').splitlines():
501
+ ln = ln.strip()
502
+ if not ln:
503
+ continue
504
+ try:
505
+ rec = json.loads(ln)
506
+ except Exception: # noqa: BLE001
507
+ continue
508
+ if isinstance(rec, dict) and rec.get('eventId'):
509
+ out.append(rec)
510
+ return out
511
+
512
+
513
+ def write_outbox(events: list) -> None:
514
+ """重写 outbox(丢最旧的超出部分,并计数)。"""
515
+ dropped = 0
516
+ evs = events
517
+ if len(evs) > OUTBOX_MAX_EVENTS:
518
+ dropped = len(evs) - OUTBOX_MAX_EVENTS
519
+ evs = evs[-OUTBOX_MAX_EVENTS:]
520
+ body = ''.join(json.dumps(e, ensure_ascii=False) + '\n' for e in evs)
521
+ while len(body.encode('utf-8')) > OUTBOX_MAX_BYTES and evs:
522
+ dropped += 1
523
+ evs = evs[1:]
524
+ body = ''.join(json.dumps(e, ensure_ascii=False) + '\n' for e in evs)
525
+ try:
526
+ outbox_path().parent.mkdir(parents=True, exist_ok=True)
527
+ with open(outbox_path(), 'w', encoding='utf-8') as f:
528
+ # 重写 outbox:同上,策略 (b) 响亮降级(外层已 try/except 兜住"遥测绝不影响调用方")
529
+ with best_effort(f, site='telemetry.rewrite', note='outbox 重写降级为无锁写'):
530
+ f.write(body)
531
+ f.flush()
532
+ except Exception: # noqa: BLE001
533
+ return
534
+ if dropped:
535
+ write_state(droppedEvents=int(read_state().get('droppedEvents') or 0) + dropped)
536
+
537
+
538
+ def purge_outbox(reason: str = '') -> int:
539
+ """**清空**本地队列(只删本地文件,绝不联网)。返回清掉的条数。
540
+
541
+ 用在两处:① 关闭状态下 `flush`("关闭期不采集、不堆积、不补发");
542
+ ② `--purge`(设置页关掉开关 / 运维手动撤回同意)。
543
+ 计数写进 `state.json` 便于事后核对("到底扔了多少")。
544
+ """
545
+ n = 0
546
+ try:
547
+ n = len(read_outbox())
548
+ p = outbox_path()
549
+ if p.exists():
550
+ p.unlink()
551
+ if n or reason:
552
+ write_state(**{
553
+ 'purgedTotal': int(read_state().get('purgedTotal') or 0) + n,
554
+ 'lastPurgeAt': dt.datetime.now().isoformat(timespec='seconds'),
555
+ 'lastPurgeReason': str(reason or ''),
556
+ })
557
+ except Exception: # noqa: BLE001
558
+ pass
559
+ return n
560
+
561
+
562
+ def split_consented(events: list) -> tuple:
563
+ """按**采集时刻的同意标记**分流 ⇒ (可发送, 无标记条数)。
564
+
565
+ 无标记 = 升级前("默认开"时代)采集的遗留事件,或任何绕过 `emit()` 塞进来的行。
566
+ 它们**永不外发**(迁移语义见模块头)。
567
+ """
568
+ keep = [e for e in events if e.get('consent') == CONSENT_TAG]
569
+ return keep, len(events) - len(keep)
570
+
571
+
572
+ def build_event(kind: str, *, capability='', flow='', role='', case='', phase='',
573
+ error_class='', attempt=0, duration_sec=0, detail='', is_test=False) -> dict:
574
+ inst = ensure_instance()
575
+ return {
576
+ 'eventId': uuid.uuid4().hex,
577
+ 'kind': kind,
578
+ 'instanceId': inst.get('installId', ''),
579
+ 'consent': CONSENT_TAG, # 采集时刻的同意标记(**只在本地**,_post 会剔除)
580
+ 'ts': dt.datetime.now(dt.timezone.utc).isoformat(timespec='seconds'),
581
+ 'version': repo_version(),
582
+ 'capability': capability or '',
583
+ 'flow': flow or '',
584
+ 'role': role or '',
585
+ 'caseHash': case_hash(case),
586
+ 'phase': phase or '',
587
+ 'errorClass': error_class or '',
588
+ 'attempt': int(attempt or 0),
589
+ 'durationSec': int(duration_sec or 0),
590
+ # 2026-10-04(合规):**不再上报原始 hostname**,改上报不可逆短哈希(os/arch 保留)。
591
+ 'hostHash': inst.get('hostHash') or host_hash(str(inst.get('host') or ''), str(inst.get('hostSalt') or '')),
592
+ 'os': inst.get('os', ''),
593
+ 'arch': inst.get('arch', ''),
594
+ 'profile': (os.environ.get('DSH_PROFILE') or 'web')[:32],
595
+ 'packVersions': pack_versions(),
596
+ 'detail': redact(detail),
597
+ 'isTest': bool(is_test),
598
+ }
599
+
600
+
601
+ def emit(kind: str, **kw) -> dict | None:
602
+ """采集一条事件(**只落本地**,不联网;联网交给 flush)。任何异常都吞掉。"""
603
+ try:
604
+ if not telemetry_enabled() or kind not in EVENT_KINDS:
605
+ return None
606
+ ev = build_event(kind, **kw)
607
+ _append_locked(outbox_path(), json.dumps(ev, ensure_ascii=False) + '\n')
608
+ write_state(lastEmitAt=ev['ts'], lastEmitKind=kind)
609
+ return ev
610
+ except Exception: # noqa: BLE001
611
+ return None
612
+
613
+
614
+ def heartbeat(force: bool = False) -> dict | None:
615
+ """心跳:每天最多一条("客户机还活着"的唯一信号)。
616
+
617
+ ⚠️ 2026-10-04(合规):**不再把主机名写进 detail**(旧实现是 `detail=f'host={...}'`)。
618
+ 要分辨机器看 `instanceId` / `hostHash` 两个字段就够了。
619
+ """
620
+ today = dt.date.today().isoformat()
621
+ st = read_state()
622
+ if not force and st.get('lastHeartbeatDate') == today:
623
+ return None
624
+ ev = emit('heartbeat', detail='')
625
+ if ev:
626
+ write_state(lastHeartbeatDate=today)
627
+ return ev
628
+
629
+
630
+ def _post(ev: dict) -> tuple[bool, int, str]:
631
+ cred = credential()
632
+ if cred['kind'] == 'none':
633
+ # fail-closed:没有可用凭证就不发(宁可不报,也不匿名上报)。`note` 说明为什么。
634
+ return False, 0, (cred.get('note') or 'no-token')
635
+ # `eventId` 按协议回填;`consent` 是**本地闸门**,绝不出网(服务端字段契约一个字不变)。
636
+ body = {k: v for k, v in ev.items() if k not in ('eventId', 'consent')} | {'eventId': ev.get('eventId', '')}
637
+ headers = {'Content-Type': 'application/json'}
638
+ if cred['kind'] == 'instance':
639
+ # 机器自带凭证:令牌进专用头;⛔ 不带 Authorization(避免服务端把过期的账号 JWT 也看一遍)
640
+ headers.update(cred['headers'])
641
+ else:
642
+ headers['Authorization'] = 'Bearer ' + cred['token']
643
+ req = urllib.request.Request(
644
+ api_base() + ENDPOINT,
645
+ data=json.dumps(body, ensure_ascii=False).encode('utf-8'),
646
+ headers=headers,
647
+ method='POST',
648
+ )
649
+ try:
650
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
651
+ if 200 <= resp.status < 300:
652
+ return True, resp.status, ''
653
+ return False, resp.status, f'http {resp.status}'
654
+ except urllib.error.HTTPError as e: # 4xx/5xx 有状态码
655
+ return False, int(getattr(e, 'code', 0) or 0), f'http {e.code}'
656
+ except Exception as e: # noqa: BLE001 网络类
657
+ return False, 0, type(e).__name__
658
+
659
+
660
+ def _telemetry_off() -> bool:
661
+ """测试/离线开关(2026-09-30):`ARGENT_TELEMETRY_OFF=1` ⇒ **绝不发网**(仅本地队列)。
662
+ 背景:测试夹具流程(如 `flowA`)曾把"流程异常·needs_agent"推到生产告警(维护者被刷)。"""
663
+ return str(os.environ.get("ARGENT_TELEMETRY_OFF", "")).strip() in ("1", "true", "True", "yes")
664
+
665
+
666
+ def flush(max_events: int = 20, force: bool = False) -> dict:
667
+ if _telemetry_off():
668
+ return {"ok": True, "skipped": "ARGENT_TELEMETRY_OFF=1(测试/离线)"}
669
+ """把 outbox 里的队头若干条发出去。退避期内直接跳过(不刷屏、不耗流量)。
670
+
671
+ `force=True` 忽略退避(运维手改端点后"立刻重试一次"、以及测试用)。
672
+ """
673
+ res = {'attempted': 0, 'sent': 0, 'failed': 0, 'skipped': ''}
674
+ try:
675
+ if not telemetry_enabled():
676
+ # 「关闭期不采集、不堆积、不补发」:关闭状态下顺手把本地队列清空(只删本地文件)。
677
+ n = purge_outbox('flush-disabled')
678
+ res['skipped'] = 'disabled'
679
+ if n:
680
+ res['purgedWhileDisabled'] = n
681
+ res['skipped'] = f'disabled(已清空本地队列 {n} 条)'
682
+ return res
683
+ st = read_state()
684
+ until = float(st.get('backoffUntil') or 0)
685
+ if until > time.time() and not force:
686
+ res['skipped'] = f'backoff {int(until - time.time())}s'
687
+ return res
688
+ queue = read_outbox()
689
+ # 迁移语义:**无同意标记**的事件(升级前"默认开"时代采集的遗留)永不外发 ⇒ 先剔除落盘。
690
+ if any(e.get('consent') != CONSENT_TAG for e in queue):
691
+ queue, untagged = split_consented(queue)
692
+ write_outbox(queue)
693
+ write_state(droppedUnconsented=int(read_state().get('droppedUnconsented') or 0) + untagged)
694
+ res['droppedUnconsented'] = untagged
695
+ if not queue:
696
+ res['skipped'] = 'empty'
697
+ return res
698
+ sent_ids, remain = [], []
699
+ bail = False
700
+ for ev in queue[:max_events]:
701
+ if bail: # 服务端不可用就别继续打
702
+ remain.append(ev)
703
+ continue
704
+ ok, code, why = _post(ev)
705
+ res['attempted'] += 1
706
+ if ok:
707
+ res['sent'] += 1
708
+ sent_ids.append(ev)
709
+ else:
710
+ res['failed'] += 1
711
+ res['lastError'] = f'{why}'
712
+ remain.append(ev)
713
+ if code in GONE_CODES or code == 0:
714
+ bail = True # 长退避:未登录/未订阅/端点未部署/断网
715
+ remain.extend(queue[max_events:])
716
+ write_outbox(remain)
717
+ if sent_ids:
718
+ _append_locked(sent_path(), ''.join(
719
+ json.dumps({'eventId': e['eventId'], 'kind': e['kind'], 'at': dt.datetime.now().isoformat(timespec='seconds')},
720
+ ensure_ascii=False) + '\n' for e in sent_ids))
721
+ _trim_sent()
722
+ write_state(lastSuccessAt=dt.datetime.now().isoformat(timespec='seconds'),
723
+ sentTotal=int(read_state().get('sentTotal') or 0) + len(sent_ids))
724
+ if res['failed']:
725
+ fails = int(read_state().get('consecutiveFailures') or 0) + 1
726
+ backoff = min(BACKOFF_BASE_SEC * (2 ** (fails - 1)), BACKOFF_MAX_SEC)
727
+ write_state(consecutiveFailures=fails, backoffUntil=time.time() + backoff,
728
+ lastError=res.get('lastError', ''), lastErrorAt=dt.datetime.now().isoformat(timespec='seconds'))
729
+ res['backoffSec'] = int(backoff)
730
+ else:
731
+ write_state(consecutiveFailures=0, backoffUntil=0, lastError='')
732
+ return res
733
+ except Exception as e: # noqa: BLE001
734
+ res['skipped'] = f'error {type(e).__name__}'
735
+ return res
736
+
737
+
738
+ def _trim_sent() -> None:
739
+ try:
740
+ lines = sent_path().read_text(encoding='utf-8', errors='replace').splitlines()
741
+ if len(lines) > SENT_RING:
742
+ sent_path().write_text('\n'.join(lines[-SENT_RING:]) + '\n', encoding='utf-8')
743
+ except Exception: # noqa: BLE001
744
+ pass
745
+
746
+
747
+ def status() -> dict:
748
+ st = read_state()
749
+ q = read_outbox()
750
+ sendable, untagged = split_consented(q)
751
+ inst = ensure_instance()
752
+ until = float(st.get('backoffUntil') or 0)
753
+ cred = credential()
754
+ tok = instance_token()
755
+ exp = str(tok.get('exp') or '')
756
+ expired = False
757
+ if exp:
758
+ try:
759
+ expired = dt.datetime.fromisoformat(exp.replace('Z', '+00:00')) < dt.datetime.now(dt.timezone.utc)
760
+ except Exception: # noqa: BLE001
761
+ expired = False
762
+ me = str(inst.get('installId') or '')
763
+ return {
764
+ 'enabled': telemetry_enabled(),
765
+ 'optIn': _config_opt_in(), # 配置里的显式同意(不含硬关的影响)
766
+ 'hardOff': os.environ.get('TELEMETRY_DISABLE') in ('1', 'true', 'yes'),
767
+ 'endpoint': api_base() + ENDPOINT,
768
+ 'hasToken': bool(cred['token']),
769
+ # ── 凭证来源(2026-10-05「机器自带凭证」)──────────────────────────────
770
+ # ⛔ **只显示类型/来源,绝不显示值**(令牌串绝不进日志/状态输出)。
771
+ 'credential': {
772
+ 'kind': cred['kind'], # instance | account | none
773
+ 'source': ('instance-token' if cred['kind'] == 'instance' and cred['source'].startswith('file:')
774
+ else 'instance-token-env' if cred['kind'] == 'instance'
775
+ else 'account-jwt' if cred['kind'] == 'account' else 'none'),
776
+ 'header': (INSTANCE_TOKEN_HEADER if cred['kind'] == 'instance'
777
+ else 'authorization' if cred['kind'] == 'account' else ''),
778
+ 'note': cred.get('note', ''),
779
+ # 下面是**元数据**(不是令牌本身):到期日 / 绑定 / 是否与令牌声明的实例一致
780
+ 'exp': exp, 'expired': expired,
781
+ 'tokenInstanceId': tok.get('instanceId', ''),
782
+ 'tokenDeviceBound': bool(tok.get('deviceCode')),
783
+ 'localInstanceId': me,
784
+ 'instanceMatch': (not tok.get('instanceId')) or tok.get('instanceId') == me,
785
+ 'tokenFile': str(instance_token_path()),
786
+ },
787
+ 'instanceId': me,
788
+ # `host` 是**本机自检**用(只在这个终端里看得到,绝不随事件外发);外发字段是 hostHash。
789
+ 'host': inst.get('host', ''),
790
+ 'hostHash': inst.get('hostHash', ''),
791
+ 'version': repo_version(),
792
+ 'queued': len(sendable),
793
+ 'queuedKinds': {k: sum(1 for e in sendable if e.get('kind') == k) for k in EVENT_KINDS},
794
+ 'queuedUnconsented': untagged, # 无同意标记(升级前遗留)⇒ 永不外发,下次 flush 剔除
795
+ 'sentTotal': int(st.get('sentTotal') or 0),
796
+ 'droppedEvents': int(st.get('droppedEvents') or 0),
797
+ 'droppedUnconsented': int(st.get('droppedUnconsented') or 0),
798
+ 'purgedTotal': int(st.get('purgedTotal') or 0),
799
+ 'consecutiveFailures': int(st.get('consecutiveFailures') or 0),
800
+ 'backoffSecLeft': max(0, int(until - time.time())),
801
+ 'lastSuccessAt': st.get('lastSuccessAt', ''),
802
+ 'lastError': st.get('lastError', ''),
803
+ 'lastHeartbeatDate': st.get('lastHeartbeatDate', ''),
804
+ }
805
+
806
+
807
+ def preview(limit: int = 5) -> list:
808
+ """打印"**将要发什么**" —— 只列真会被发出去的(带同意标记的)那些。"""
809
+ return split_consented(read_outbox())[0][:limit]
810
+
811
+
812
+ # ── CLI ──────────────────────────────────────────────────────────────────────
813
+ def main(argv=None) -> int:
814
+ ap = argparse.ArgumentParser(description='遥测上报(元数据事件;绝不阻断主流程)')
815
+ ap.add_argument('--emit', action='store_true', help='采集一条事件(只落本地队列)')
816
+ ap.add_argument('--heartbeat', action='store_true', help='每天一条心跳(去重)')
817
+ ap.add_argument('--force', action='store_true', help='心跳忽略"今天已发"')
818
+ ap.add_argument('--flush', action='store_true', help='把队列发出去(有退避)')
819
+ ap.add_argument('--max', type=int, default=20, help='--flush 单次最多发几条')
820
+ ap.add_argument('--preview', action='store_true', help='打印将要上报的内容(脱敏后)')
821
+ ap.add_argument('--status', action='store_true', help='打印队列/退避/实例身份')
822
+ ap.add_argument('--purge', action='store_true', help='清空本地队列(关闭上报/撤回同意时用;只删本地文件)')
823
+ ap.add_argument('--init-instance', action='store_true', help='创建 instance.json')
824
+ ap.add_argument('--kind', choices=EVENT_KINDS, default='failure')
825
+ ap.add_argument('--capability', default='')
826
+ ap.add_argument('--flow', default='')
827
+ ap.add_argument('--role', default='')
828
+ ap.add_argument('--case', default='', help='票号(只上报其哈希)')
829
+ ap.add_argument('--phase', default='')
830
+ ap.add_argument('--error-class', dest='error_class', default='')
831
+ ap.add_argument('--attempt', type=int, default=0)
832
+ ap.add_argument('--duration-sec', dest='duration_sec', type=int, default=0)
833
+ ap.add_argument('--detail', default='')
834
+ ap.add_argument('--test', action='store_true', help='演练事件(服务端不推维护者)')
835
+ a = ap.parse_args(argv)
836
+
837
+ try:
838
+ if a.init_instance:
839
+ inst = ensure_instance()
840
+ print(json.dumps(inst, ensure_ascii=False, indent=2))
841
+ return 0
842
+ if a.status:
843
+ print(json.dumps(status(), ensure_ascii=False, indent=2))
844
+ return 0
845
+ if a.purge:
846
+ n = purge_outbox('cli')
847
+ print(f'purge: 已清空本地队列 {n} 条(未联网)')
848
+ return 0
849
+ if a.preview:
850
+ for ev in preview(a.max):
851
+ print(json.dumps(ev, ensure_ascii=False, indent=2))
852
+ return 0
853
+ if a.heartbeat:
854
+ ev = heartbeat(force=a.force)
855
+ print('heartbeat: ' + ('queued' if ev else 'skipped(今天已发)'))
856
+ if a.emit:
857
+ ev = emit(a.kind, capability=a.capability, flow=a.flow, role=a.role, case=a.case,
858
+ phase=a.phase, error_class=a.error_class, attempt=a.attempt,
859
+ duration_sec=a.duration_sec, detail=a.detail, is_test=a.test)
860
+ print('emit: ' + ('queued' if ev else 'skipped(关闭或参数不合法)'))
861
+ if a.flush or a.heartbeat or a.emit:
862
+ r = flush(a.max, force=a.force)
863
+ print('flush: ' + json.dumps(r, ensure_ascii=False))
864
+ if not any([a.emit, a.heartbeat, a.flush, a.preview, a.status, a.init_instance, a.purge]):
865
+ ap.print_help()
866
+ return 0
867
+ except Exception as e: # noqa: BLE001 —— 遥测永远不许把调用方搞崩
868
+ print(f'telemetry 忽略异常: {type(e).__name__}: {e}', file=sys.stderr)
869
+ return 0
870
+
871
+
872
+ if __name__ == '__main__':
873
+ raise SystemExit(main())