@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24

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 (327) hide show
  1. package/README.md +102 -194
  2. package/README.zh-CN.md +115 -210
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-bootstrap.js +466 -35
  5. package/bin/gotry-inner.js +255 -36
  6. package/bin/gotry-process-liveness.d.ts +27 -0
  7. package/bin/gotry-process-liveness.js +97 -0
  8. package/bin/gotry-runtime-resolution.d.ts +1 -1
  9. package/bin/gotry-runtime-resolution.js +2 -2
  10. package/client/client.js +174 -0
  11. package/cordis.gotry-patch.yml +70 -93
  12. package/dist/capabilities/agent-reach-deep.js +45 -122
  13. package/dist/capabilities/agent-reach.js +63 -148
  14. package/dist/capabilities/anything.js +89 -75
  15. package/dist/capabilities/artifacts.js +123 -104
  16. package/dist/capabilities/channel-health.js +65 -45
  17. package/dist/capabilities/channel-registry.js +108 -87
  18. package/dist/capabilities/doctor.js +357 -235
  19. package/dist/capabilities/effect.js +223 -309
  20. package/dist/capabilities/fact-log.js +28 -14
  21. package/dist/capabilities/flyai.js +245 -156
  22. package/dist/capabilities/ground-transfer.js +509 -0
  23. package/dist/capabilities/hbcli.js +144 -148
  24. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  25. package/dist/capabilities/incident-log.js +131 -61
  26. package/dist/capabilities/model-override.js +30 -10
  27. package/dist/capabilities/opensky.js +54 -43
  28. package/dist/capabilities/resilience.js +50 -32
  29. package/dist/capabilities/session/action-cache.js +50 -44
  30. package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
  31. package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
  32. package/dist/capabilities/session/adapters/dida-portal.js +206 -0
  33. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  34. package/dist/capabilities/session/adapters/rail-12306.js +169 -112
  35. package/dist/capabilities/session/benchmark.js +85 -149
  36. package/dist/capabilities/session/extension-bridge.js +320 -259
  37. package/dist/capabilities/session/extension-channel.js +57 -76
  38. package/dist/capabilities/session/extension-distribution.js +115 -119
  39. package/dist/capabilities/session/extract.js +31 -21
  40. package/dist/capabilities/session/golden-score.js +55 -33
  41. package/dist/capabilities/session/health-watch.js +146 -154
  42. package/dist/capabilities/session/read-guard.js +55 -47
  43. package/dist/capabilities/session/static-flight-golden.js +49 -36
  44. package/dist/capabilities/session/transport.js +54 -61
  45. package/dist/capabilities/session/wizard.js +63 -93
  46. package/dist/capabilities/session-consent.js +143 -47
  47. package/dist/capabilities/session-login.js +95 -126
  48. package/dist/capabilities/session-search.js +391 -253
  49. package/dist/capabilities/sponsor-plugin.js +341 -0
  50. package/dist/capabilities/visa-policy.js +87 -73
  51. package/dist/capabilities/weather.js +163 -235
  52. package/dist/scripts/action-cache-tests.js +24 -59
  53. package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
  54. package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
  55. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  56. package/dist/scripts/agent-reach-tests.js +26 -18
  57. package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
  58. package/dist/scripts/anything-tests.js +58 -62
  59. package/dist/scripts/artifact-client-contract-tests.js +69 -0
  60. package/dist/scripts/artifacts-capability-tests.js +235 -0
  61. package/dist/scripts/async-collect.js +26 -15
  62. package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
  63. package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
  64. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
  65. package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
  66. package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
  67. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
  68. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
  69. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
  70. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
  71. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
  72. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
  73. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
  74. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
  75. package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
  76. package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
  77. package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
  78. package/dist/scripts/booking-executor-tests.js +80 -0
  79. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  80. package/dist/scripts/booking-saga-tests.js +61 -123
  81. package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
  82. package/dist/scripts/bootstrap-tests.js +1383 -128
  83. package/dist/scripts/build-changelog.js +89 -130
  84. package/dist/scripts/build-metrics-report.js +135 -143
  85. package/dist/scripts/changelog-tests.js +35 -82
  86. package/dist/scripts/channel-probe-tests.js +44 -116
  87. package/dist/scripts/channel-probe.js +84 -167
  88. package/dist/scripts/channel-registry-tests.js +97 -185
  89. package/dist/scripts/companion-tests.js +25 -70
  90. package/dist/scripts/dida-cookie-probe.js +87 -0
  91. package/dist/scripts/dida-sw-probe.js +114 -0
  92. package/dist/scripts/diff-test.js +17 -12
  93. package/dist/scripts/doctor-tests.js +428 -120
  94. package/dist/scripts/dsh-artifact-e2e.js +150 -0
  95. package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
  96. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  97. package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
  98. package/dist/scripts/dsh-runtime-closure.js +75 -63
  99. package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
  100. package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
  101. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  102. package/dist/scripts/effect-tests.js +165 -552
  103. package/dist/scripts/engine-run.js +1 -3
  104. package/dist/scripts/engine-tests.js +16 -10
  105. package/dist/scripts/evaluation-cadence-tests.js +69 -242
  106. package/dist/scripts/evaluation-contract-tests.js +193 -499
  107. package/dist/scripts/extension-distribution-cli.js +19 -15
  108. package/dist/scripts/extension-distribution-tests.js +98 -211
  109. package/dist/scripts/extension-tests.js +366 -389
  110. package/dist/scripts/fact-gate-tests.js +725 -263
  111. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  112. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  113. package/dist/scripts/flight-malformed-tests.js +159 -0
  114. package/dist/scripts/flyai-tests.js +325 -68
  115. package/dist/scripts/g5-guard-tests.js +131 -0
  116. package/dist/scripts/g5-guard.js +189 -0
  117. package/dist/scripts/gen-lunar-anchors.js +106 -0
  118. package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
  119. package/dist/scripts/ground-transfer-tests.js +956 -0
  120. package/dist/scripts/hbcli-e2e-tests.js +71 -90
  121. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  122. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  123. package/dist/scripts/hbcli-tests.js +41 -115
  124. package/dist/scripts/health-watch-cli.js +42 -28
  125. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  126. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  127. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  128. package/dist/scripts/i18n-tests.js +16 -9
  129. package/dist/scripts/incident-tests.js +281 -87
  130. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  131. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  132. package/dist/scripts/issue-271-liveness-tests.js +547 -0
  133. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  134. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  135. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  136. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  137. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  138. package/dist/scripts/journey-tests.js +22 -27
  139. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  140. package/dist/scripts/kernel-manifest-gate.js +126 -0
  141. package/dist/scripts/kernel-manifest-tests.js +212 -0
  142. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  143. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  144. package/dist/scripts/kernel-manifest.js +369 -0
  145. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  146. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  147. package/dist/scripts/ledger-tests.js +325 -340
  148. package/dist/scripts/ledger-workflow-crash.js +10 -20
  149. package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
  150. package/dist/scripts/memory-capture-tests.js +242 -61
  151. package/dist/scripts/memory-decay-tests.js +18 -52
  152. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  153. package/dist/scripts/memory-lifecycle.js +196 -0
  154. package/dist/scripts/memory-metrics.js +24 -12
  155. package/dist/scripts/memory-value-report.js +474 -222
  156. package/dist/scripts/memory-value-tests.js +328 -0
  157. package/dist/scripts/metrics-report-tests.js +62 -199
  158. package/dist/scripts/model-override-e2e.js +66 -105
  159. package/dist/scripts/nightly-evidence-tests.js +64 -89
  160. package/dist/scripts/nightly-evidence.js +108 -111
  161. package/dist/scripts/nudge-digest.js +39 -28
  162. package/dist/scripts/onboarding-tests.js +63 -88
  163. package/dist/scripts/opensky-check.js +18 -13
  164. package/dist/scripts/opensky-tests.js +28 -28
  165. package/dist/scripts/persona-surface-guard-tests.js +37 -12
  166. package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
  167. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  168. package/dist/scripts/price-drift-tests.js +126 -227
  169. package/dist/scripts/price-drift-watch.js +209 -253
  170. package/dist/scripts/probe-poi-tests.js +52 -45
  171. package/dist/scripts/product-metrics.js +187 -273
  172. package/dist/scripts/publish-preverify.js +47 -41
  173. package/dist/scripts/realtime-pricing-tests.js +37 -76
  174. package/dist/scripts/replay-async.js +20 -18
  175. package/dist/scripts/replay-real.js +16 -18
  176. package/dist/scripts/replay.js +52 -82
  177. package/dist/scripts/session-attach-diagnose.js +16 -12
  178. package/dist/scripts/session-attach-poc.js +34 -37
  179. package/dist/scripts/session-benchmark.js +66 -147
  180. package/dist/scripts/session-dida-live-e2e.js +151 -0
  181. package/dist/scripts/session-extract-tests.js +14 -35
  182. package/dist/scripts/session-login.js +14 -13
  183. package/dist/scripts/session-tests.js +545 -583
  184. package/dist/scripts/sf-live-benchmark.js +92 -110
  185. package/dist/scripts/sf-live-cli-tests.js +2 -9
  186. package/dist/scripts/sf-soft-score-tests.js +19 -60
  187. package/dist/scripts/sf-summary-tests.js +149 -0
  188. package/dist/scripts/sf-summary.js +466 -84
  189. package/dist/scripts/skeleton-check.js +16 -14
  190. package/dist/scripts/skeleton-integration-test.js +11 -10
  191. package/dist/scripts/skills-contract-tests.js +51 -46
  192. package/dist/scripts/smoke-session-gate-tests.js +12 -20
  193. package/dist/scripts/smoke.js +541 -422
  194. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  195. package/dist/scripts/state-cli-tests.js +226 -101
  196. package/dist/scripts/state-cli.js +549 -212
  197. package/dist/scripts/static-golden-tests.js +46 -180
  198. package/dist/scripts/time-eval-tests.js +210 -192
  199. package/dist/scripts/travel-timeline-tests.js +29 -93
  200. package/dist/scripts/turn-handoff-collect-tests.js +46 -93
  201. package/dist/scripts/turn-handoff-collect.js +71 -99
  202. package/dist/scripts/turn-policy-tests.js +28 -29
  203. package/dist/scripts/typed-contract-canary.js +77 -162
  204. package/dist/scripts/tz-resolver-sanity.js +50 -0
  205. package/dist/scripts/tz-resolver-tests.js +584 -0
  206. package/dist/scripts/unified-tests.js +23 -19
  207. package/dist/scripts/visa-policy-tests.js +31 -44
  208. package/dist/scripts/weather-tests.js +121 -543
  209. package/dist/scripts/wish-channel-gate-tests.js +29 -86
  210. package/dist/scripts/write-gate-tests.js +522 -0
  211. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  212. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  213. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  214. package/dist/scripts/z3-race-tests.js +18 -12
  215. package/dist/src/artifact-gate.js +652 -221
  216. package/dist/src/backend/kernel.js +91 -0
  217. package/dist/src/backend/modules/booking-copilot.js +55 -0
  218. package/dist/src/backend/modules/booking-executor.js +124 -0
  219. package/dist/src/backend/modules/session-search.js +194 -0
  220. package/dist/src/benchmark-agent-conformance.js +296 -262
  221. package/dist/src/benchmark-environment-bridge.js +298 -413
  222. package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
  223. package/dist/src/benchmark-tool-isolation.js +94 -67
  224. package/dist/src/bookable-facts.js +299 -174
  225. package/dist/src/booking-saga.js +64 -120
  226. package/dist/src/booking-surface/availability-policy.js +443 -576
  227. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  228. package/dist/src/booking-surface/contracts.js +24 -80
  229. package/dist/src/booking-surface/dsh-planner.js +510 -281
  230. package/dist/src/booking-surface/dsh-plugin.js +17 -1
  231. package/dist/src/booking-surface/error-codes.js +82 -10
  232. package/dist/src/booking-surface/index.js +0 -3
  233. package/dist/src/booking-surface/profile.js +10 -54
  234. package/dist/src/booking-surface/recovery-chain.js +367 -0
  235. package/dist/src/booking-surface/runtime.js +765 -891
  236. package/dist/src/booking-surface/server.js +200 -251
  237. package/dist/src/booking-surface/startup.js +58 -54
  238. package/dist/src/booking-surface/validation.js +150 -218
  239. package/dist/src/bridge.js +20 -13
  240. package/dist/src/companions.js +48 -74
  241. package/dist/src/contracts.js +21 -43
  242. package/dist/src/dsh-llm.js +82 -120
  243. package/dist/src/engine.js +253 -239
  244. package/dist/src/evaluation-cadence.js +72 -209
  245. package/dist/src/evaluation-contracts.js +220 -843
  246. package/dist/src/flight-pack-adapter.js +54 -0
  247. package/dist/src/flight-pack-contract.js +2 -0
  248. package/dist/src/gotry-backend.js +70 -0
  249. package/dist/src/hotel-date-gate.js +118 -0
  250. package/dist/src/i18n.js +18 -7
  251. package/dist/src/index.js +1425 -1629
  252. package/dist/src/journey.js +132 -81
  253. package/dist/src/ledger-repair-apply.js +538 -0
  254. package/dist/src/ledger-repair-plan.js +463 -0
  255. package/dist/src/loop.js +257 -166
  256. package/dist/src/memory-capture.js +180 -32
  257. package/dist/src/memory-decay.js +30 -14
  258. package/dist/src/memory-lifecycle.js +1310 -0
  259. package/dist/src/memory-utility.js +27 -40
  260. package/dist/src/mock-llm.js +36 -61
  261. package/dist/src/model.js +81 -36
  262. package/dist/src/realtime-pricing.js +63 -72
  263. package/dist/src/slot-spec.js +84 -106
  264. package/dist/src/state-ledger.js +310 -471
  265. package/dist/src/subagent-job-id-guard.js +58 -0
  266. package/dist/src/time-anchor.js +231 -33
  267. package/dist/src/tool-packet.js +20 -8
  268. package/dist/src/travel-slots.js +58 -35
  269. package/dist/src/travel-timeline.js +44 -63
  270. package/dist/src/turn-deadline.js +121 -127
  271. package/dist/src/turn-policy.js +61 -89
  272. package/dist/src/tz-resolver.js +143 -0
  273. package/dist/src/unified.js +504 -381
  274. package/dist/src/wish-pool.js +39 -14
  275. package/dist/src/write-gate.js +495 -0
  276. package/dist/src/z3-shared.js +273 -7
  277. package/extension/README.md +4 -3
  278. package/extension/background.js +125 -14
  279. package/extension/content-bridge.js +23 -1
  280. package/extension/content-main.js +15 -4
  281. package/extension/manifest.json +16 -8
  282. package/package.json +251 -220
  283. package/ts/capabilities/agent-reach.ts +4 -4
  284. package/ts/capabilities/anything.ts +1 -1
  285. package/ts/capabilities/artifacts.ts +46 -9
  286. package/ts/capabilities/flyai.ts +156 -37
  287. package/ts/capabilities/hbcli.ts +1 -1
  288. package/ts/capabilities/incident-log.ts +88 -59
  289. package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
  290. package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
  291. package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
  292. package/ts/capabilities/session/extension-bridge.ts +226 -101
  293. package/ts/capabilities/session/extension-channel.ts +22 -9
  294. package/ts/capabilities/session/wizard.ts +2 -2
  295. package/ts/capabilities/session-consent.ts +98 -14
  296. package/ts/capabilities/session-login.ts +1 -0
  297. package/ts/capabilities/session-search.ts +315 -31
  298. package/ts/dsh-runtime/vendor/README.md +8 -4
  299. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
  300. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
  301. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
  302. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
  303. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
  304. package/ts/package.json +236 -16
  305. package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
  306. package/ts/scripts/state-cli.ts +418 -51
  307. package/ts/src/artifact-gate.ts +561 -15
  308. package/ts/src/benchmark-environment-bridge.ts +37 -20
  309. package/ts/src/bookable-facts.ts +183 -7
  310. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  311. package/ts/src/booking-surface/dsh-planner.ts +240 -32
  312. package/ts/src/booking-surface/dsh-plugin.js +17 -1
  313. package/ts/src/booking-surface/error-codes.ts +75 -0
  314. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  315. package/ts/src/booking-surface/server.ts +84 -51
  316. package/ts/src/booking-surface/validation.ts +2 -0
  317. package/ts/src/contracts.ts +12 -2
  318. package/ts/src/dsh-llm.ts +4 -8
  319. package/ts/src/index.ts +609 -90
  320. package/ts/src/loop.ts +116 -7
  321. package/ts/src/memory-capture.ts +227 -3
  322. package/ts/src/mock-llm.ts +4 -8
  323. package/ts/src/model.ts +88 -2
  324. package/ts/src/state-ledger.ts +47 -29
  325. package/ts/src/subagent-job-id-guard.ts +72 -0
  326. package/ts/src/time-anchor.ts +217 -13
  327. package/ts/src/unified.ts +316 -54
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Booking Copilot unavailable/changed 恢复链契约测试(issue #142 非门控切片;全离线纯函数,零真实供应商调用):
3
+ * 1. 检测分类(复用 ActionReceipt status 闭集,不发明新通道):confirmed / changed(版本被取代=价格变动/房型关闭)/
4
+ * unavailable(完整负证据)/ inconclusive(partial/stale/failed/gap——不得宣称「市场无房」)/ unrelated(非 offer.check 观测)
5
+ * 2. 分类 fail-closed:available+empty outcome、!available 却携 verifiedOfferRef、unavailable 却非 empty outcome、
6
+ * applied/changed/unavailable 却观测形态不符——全部抛 availability_receipt_incoherent 同名错误
7
+ * 3. 链开立 fail-closed:confirmed → recovery_chain_not_required;unrelated → recovery_trigger_absent;
8
+ * taskId/contextRef/originalSelection 绑定键缺一即拒
9
+ * 4. 受控恢复(不开新无界重试通道):approach 闭集 recheck_version/requery_same_hotel/alternative_candidate;
10
+ * 每次计入预算的恢复轮对齐 planner 三次调用预算(第 4 次拒绝);终态吸收,其后不得再有 attempt
11
+ * 5. 用户沟通红线:恢复命中与原选择不同酒店/报价且未先披露 → recovery_silent_swap_forbidden(静默换房换航结构性拒绝);
12
+ * 先披露再解决 → 合法;空披露文本拒绝;降级必须携显式披露(静默降级拒绝)
13
+ * 6. 显式说明变化:版本位移的 resolved 必须产出非空变化说明(old→new versionRef)——不静默换版
14
+ * 7. 审计可对账(对齐 sagaTraceViolations 形态):合法链空违例;缺 detected 起点/终态后事件/静默替换/静默降级/未知 kind 全部违例
15
+ * 8. 纯函数:每次推进返回新状态,原状态不可变;receiptDigestOf 规范摘要稳定且随内容变化
16
+ *
17
+ * 运行:cd ts && npx tsx scripts/booking-recovery-chain-tests.ts
18
+ */
19
+ import assert from 'node:assert/strict';
20
+ import { RECOVERY_CHAIN_SCHEMA, MAX_RECOVERY_MODEL_TURNS_PER_TRIGGER, MAX_RECOVERY_BUDGET_NOTE, classifyRecoveryTrigger, receiptDigestOf, openRecoveryChain, attemptRecoveryStep, discloseSubstitution, degradeRecoveryChain, buildUserDisclosure, recoveryAuditViolations, } from '../src/booking-surface/recovery-chain.js';
21
+ const T0 = '2026-09-11T10:00:00.000Z';
22
+ const T1 = '2026-09-11T10:00:01.000Z';
23
+ const T2 = '2026-09-11T10:00:02.000Z';
24
+ const ORIGINAL = { hotelRef: 'hotel-1', offerRef: 'offer-1', offerVersionRef: 'v1' };
25
+ const CLEAN_CONTRACT = { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] };
26
+ function receipt(overrides) {
27
+ return {
28
+ schemaVersion: 'booking.surface',
29
+ kind: 'action.receipt',
30
+ actionId: 'act-1',
31
+ contextRef: 'ctx-1',
32
+ status: 'applied',
33
+ revision: 3,
34
+ observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: true, currentOfferVersionRef: ORIGINAL.offerVersionRef, verifiedOfferRef: 'verified-1', changedFactRefs: [] },
35
+ resultContract: CLEAN_CONTRACT,
36
+ ...overrides,
37
+ };
38
+ }
39
+ function seedChain(overrides = {}) {
40
+ return openRecoveryChain({
41
+ taskId: 'task-1',
42
+ contextRef: 'ctx-1',
43
+ receipt: receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } }),
44
+ originalSelection: ORIGINAL,
45
+ at: T0,
46
+ ...overrides,
47
+ });
48
+ }
49
+ // ── 1. 检测分类:复用 status 闭集 ────────────────────────────────────────────
50
+ const confirmedCls = classifyRecoveryTrigger(receipt({}));
51
+ assert.equal(confirmedCls.kind, 'confirmed', 'applied+available+版本一致+verified → confirmed');
52
+ assert.equal(confirmedCls.verifiedOfferRef, 'verified-1');
53
+ const changedCls = classifyRecoveryTrigger(receipt({
54
+ status: 'changed',
55
+ observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: true, currentOfferVersionRef: 'v2', verifiedOfferRef: undefined, changedFactRefs: ['fact.price'] },
56
+ }));
57
+ assert.equal(changedCls.kind, 'changed', 'changed+available+后继版本 → changed(价格变动/房型关闭的供应商形态)');
58
+ assert.equal(changedCls.currentOfferVersionRef, 'v2');
59
+ assert.deepEqual(changedCls.changedFactRefs, ['fact.price']);
60
+ const unavailableCls = classifyRecoveryTrigger(receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } }));
61
+ assert.equal(unavailableCls.kind, 'unavailable', 'unavailable+!available+empty outcome → unavailable(完整负证据)');
62
+ const noMatchCls = classifyRecoveryTrigger(receipt({ status: 'no_match', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } }));
63
+ assert.equal(noMatchCls.kind, 'unavailable', 'no_match 同归 unavailable 闭集(既有词汇)');
64
+ for (const [status, extra] of [
65
+ ['partial', { resultContract: { ...CLEAN_CONTRACT, outcome: 'partial' } }],
66
+ ['stale', {}],
67
+ ['failed', {}],
68
+ ['needs_input', {}],
69
+ ]) {
70
+ const cls = classifyRecoveryTrigger(receipt({ status, observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [], gapCodes: ['check_avail_unverified'] }, ...extra }));
71
+ assert.equal(cls.kind, 'inconclusive', `${status} → inconclusive`);
72
+ assert.match(cls.reason, /not_claim_market_empty/, 'inconclusive 必须携带「不得宣称市场无房」语义');
73
+ }
74
+ const gapCls = classifyRecoveryTrigger(receipt({ status: 'partial', observation: { kind: 'gap', code: 'check_avail_failed', factRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'partial' } }));
75
+ assert.equal(gapCls.kind, 'inconclusive', 'gap 观测 → inconclusive(不可确认 ≠ 无房)');
76
+ assert.equal(classifyRecoveryTrigger(receipt({ observation: { kind: 'offers.state', hotelRefs: [ORIGINAL.hotelRef], offerRefs: [ORIGINAL.offerRef], loadedHotelCount: 1 } })).kind, 'unrelated', 'offers.query 观测不是 offer 级恢复触发(generation 折叠归 policy 所有)');
77
+ assert.equal(classifyRecoveryTrigger(receipt({ observation: { kind: 'search.state', resultCount: 3 } })).kind, 'unrelated', 'search.state → unrelated');
78
+ // ── 2. 分类 fail-closed:不连贯 receipt 拒绝 ────────────────────────────────
79
+ assert.throws(() => classifyRecoveryTrigger(receipt({ status: 'unavailable', resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } })), /availability_receipt_incoherent/, 'available=true + unavailable status → 不连贯');
80
+ assert.throws(() => classifyRecoveryTrigger(receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, verifiedOfferRef: 'verified-x', changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } })), /availability_receipt_incoherent/, '!available 却携 verifiedOfferRef → 不连贯');
81
+ assert.throws(() => classifyRecoveryTrigger(receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'partial' } })), /availability_receipt_incoherent/, 'unavailable 却非 empty outcome → 不连贯');
82
+ assert.throws(() => classifyRecoveryTrigger(receipt({ status: 'changed', observation: { kind: 'search.state', resultCount: 1 } })), /availability_observation_required/, 'changed 却无 offer.availability 观测 → 观测缺失');
83
+ // ── 3. 链开立:fail-closed ─────────────────────────────────────────────────
84
+ assert.throws(() => openRecoveryChain({ taskId: 'task-1', contextRef: 'ctx-1', receipt: receipt({}), originalSelection: ORIGINAL, at: T0 }), /recovery_chain_not_required/, 'confirmed 无需恢复链');
85
+ assert.throws(() => openRecoveryChain({ taskId: 'task-1', contextRef: 'ctx-1', receipt: receipt({ observation: { kind: 'search.state', resultCount: 1 } }), originalSelection: ORIGINAL, at: T0 }), /recovery_trigger_absent/, 'unrelated 不开链');
86
+ assert.throws(() => seedChain({ taskId: '' }), /recovery_binding_missing/, 'taskId 缺失即拒');
87
+ assert.throws(() => seedChain({ contextRef: '' }), /recovery_binding_missing/, 'contextRef 缺失即拒');
88
+ assert.throws(() => seedChain({ originalSelection: { ...ORIGINAL, offerRef: '' } }), /recovery_binding_missing/, '原选择 offerRef 缺失即拒');
89
+ const chain = seedChain();
90
+ assert.equal(chain.schemaVersion, RECOVERY_CHAIN_SCHEMA);
91
+ assert.equal(chain.trigger, 'unavailable');
92
+ assert.equal(chain.phase, 'detected');
93
+ assert.equal(chain.countedModelTurns, 0);
94
+ assert.deepEqual(chain.originalSelection, ORIGINAL);
95
+ assert.equal(chain.history.length, 1);
96
+ assert.equal(chain.history[0].kind, 'recovery.detected');
97
+ assert.ok(chain.history[0].receiptDigest?.length === 64, 'detected 事件带 receipt 摘要');
98
+ const changedChain = openRecoveryChain({
99
+ taskId: 'task-1', contextRef: 'ctx-1',
100
+ receipt: receipt({ status: 'changed', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: true, currentOfferVersionRef: 'v2', changedFactRefs: ['fact.price'] } }),
101
+ originalSelection: ORIGINAL, at: T0,
102
+ });
103
+ assert.equal(changedChain.trigger, 'changed');
104
+ // ── 4. 受控恢复:approach 闭集 + 三轮预算 + 终态吸收 ────────────────────────
105
+ const afterQuery = attemptRecoveryStep(chain, { receipt: receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } }), target: ORIGINAL, approach: 'requery_same_hotel' }, T1);
106
+ assert.equal(afterQuery.phase, 'recovering');
107
+ assert.equal(afterQuery.countedModelTurns, 1, '恢复轮计入预算');
108
+ assert.equal(afterQuery.history.length, 2);
109
+ assert.equal(afterQuery.history[1].kind, 'recovery.attempt');
110
+ assert.deepEqual(afterQuery.history[1].target, ORIGINAL);
111
+ assert.throws(() => attemptRecoveryStep(chain, { receipt: receipt({}), target: ORIGINAL, approach: 'wild_retry' }, T1), /recovery_approach_invalid/, 'approach 不在闭集即拒(不开新重试通道)');
112
+ assert.throws(() => attemptRecoveryStep(afterQuery, { receipt: receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, verifiedOfferRef: 'verified-x', changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } }), target: ORIGINAL, approach: 'requery_same_hotel' }, T1), /availability_receipt_incoherent/, 'attempt 携带不连贯 receipt 拒绝(不可确认当证据)');
113
+ // confirmed 于同 offer 同版本 → resolved(无需替换披露)
114
+ const recovered = attemptRecoveryStep(afterQuery, { receipt: receipt({}), target: ORIGINAL, approach: 'requery_same_hotel' }, T2);
115
+ assert.equal(recovered.phase, 'resolved');
116
+ assert.deepEqual(recovered.resolvedTo, ORIGINAL);
117
+ assert.equal(recovered.history.at(-1).kind, 'recovery.resolved');
118
+ assert.throws(() => attemptRecoveryStep(recovered, { receipt: receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } }), target: ORIGINAL, approach: 'requery_same_hotel' }, T2), /recovery_chain_terminal/, '终态吸收:resolved 后不得再有 attempt');
119
+ // changed 触发 → recheck_version 路径也走闭集
120
+ const rechecked = attemptRecoveryStep(changedChain, { receipt: receipt({}), target: { ...ORIGINAL, offerVersionRef: 'v2' }, approach: 'recheck_version' }, T1);
121
+ assert.equal(rechecked.phase, 'resolved');
122
+ assert.deepEqual(rechecked.resolvedTo, { ...ORIGINAL, offerVersionRef: 'v2' });
123
+ const recheckDisclosure = buildUserDisclosure(rechecked);
124
+ assert.equal(recheckDisclosure.substitution, false, '同酒店同报价仅版本位移不是替换');
125
+ assert.equal(recheckDisclosure.versionShifted, true);
126
+ assert.match(recheckDisclosure.text, /v1/, '变化说明必须显式含旧版本');
127
+ assert.match(recheckDisclosure.text, /v2/, '变化说明必须显式含新版本');
128
+ // 三轮模型调用预算(对齐 planner attempt < 3)
129
+ let budgetChain = seedChain();
130
+ for (let turn = 0; turn < MAX_RECOVERY_MODEL_TURNS_PER_TRIGGER; turn += 1) {
131
+ budgetChain = attemptRecoveryStep(budgetChain, { receipt: receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } }), target: ORIGINAL, approach: 'requery_same_hotel' }, T1);
132
+ }
133
+ assert.equal(budgetChain.countedModelTurns, MAX_RECOVERY_MODEL_TURNS_PER_TRIGGER);
134
+ assert.ok(MAX_RECOVERY_BUDGET_NOTE.includes('3'), '预算语义说明对齐 planner 三次调用');
135
+ assert.throws(() => attemptRecoveryStep(budgetChain, { receipt: receipt({ status: 'unavailable', observation: { kind: 'offer.availability', offerRef: ORIGINAL.offerRef, checkedOfferVersionRef: ORIGINAL.offerVersionRef, available: false, changedFactRefs: [] }, resultContract: { ...CLEAN_CONTRACT, outcome: 'empty' } }), target: ORIGINAL, approach: 'requery_same_hotel' }, T1), /recovery_model_turn_budget_exhausted/, '第 4 次计入预算的恢复轮拒绝——必须降级');
136
+ // ── 5. 用户沟通红线:静默换房换航结构性拒绝 ────────────────────────────────
137
+ const substituteTarget = { hotelRef: 'hotel-2', offerRef: 'offer-9', offerVersionRef: 'v9' };
138
+ assert.throws(() => attemptRecoveryStep(afterQuery, { receipt: receipt({}), target: substituteTarget, approach: 'alternative_candidate' }, T2), /recovery_silent_swap_forbidden/, '未披露先换酒店 → 静默换房拒绝');
139
+ assert.equal(afterQuery.phase, 'recovering', '拒绝即不动(纯函数,原状态不变)');
140
+ const disclosed = discloseSubstitution(afterQuery, { target: substituteTarget, disclosureText: '原报价已不可用,替代候选为 hotel-2 的 offer-9(价格已变化)', at: T2 });
141
+ assert.equal(disclosed.substitutionDisclosed, true);
142
+ assert.equal(disclosed.history.at(-1).kind, 'recovery.disclosed');
143
+ assert.equal(disclosed.history.at(-1).substitution, true);
144
+ assert.throws(() => discloseSubstitution(afterQuery, { target: substituteTarget, disclosureText: '', at: T2 }), /recovery_disclosure_required/, '空披露文本拒绝');
145
+ const substituted = attemptRecoveryStep(disclosed, { receipt: receipt({}), target: substituteTarget, approach: 'alternative_candidate' }, T2);
146
+ assert.equal(substituted.phase, 'resolved');
147
+ assert.deepEqual(substituted.resolvedTo, substituteTarget);
148
+ const subDisclosure = buildUserDisclosure(substituted);
149
+ assert.equal(subDisclosure.substitution, true, '替换解决必须显式标记');
150
+ assert.match(subDisclosure.text, /hotel-1/, '说明必须提及原酒店');
151
+ assert.match(subDisclosure.text, /hotel-2/, '说明必须提及替代酒店');
152
+ assert.throws(() => discloseSubstitution(recovered, { target: substituteTarget, disclosureText: 'x', at: T2 }), /recovery_chain_terminal/, '终态后不得再披露');
153
+ // 降级:静默降级拒绝;显式披露后降级合法且吸收
154
+ assert.throws(() => degradeRecoveryChain(budgetChain, { reason: 'budget exhausted', disclosureText: '', at: T2 }), /recovery_degrade_requires_disclosure/, '静默降级拒绝');
155
+ const degraded = degradeRecoveryChain(budgetChain, { reason: 'recovery budget exhausted', disclosureText: '原报价已不可用且恢复预算耗尽,任务显式结束,未做任何替换', at: T2 });
156
+ assert.equal(degraded.phase, 'degraded');
157
+ assert.equal(degraded.history.at(-1).kind, 'recovery.degraded');
158
+ assert.ok(degraded.history.some((event) => event.kind === 'recovery.disclosed'), '降级前必有显式披露事件');
159
+ assert.throws(() => degradeRecoveryChain(degraded, { reason: 'again', disclosureText: 'x', at: T2 }), /recovery_chain_terminal/, '降级吸收');
160
+ // ── 6+7. 审计链校验(sagaTraceViolations 同形态) ──────────────────────────
161
+ assert.deepEqual(recoveryAuditViolations(recovered.history), [], '合法恢复链零违例');
162
+ assert.deepEqual(recoveryAuditViolations(substituted.history), [], '合法替换链零违例');
163
+ assert.deepEqual(recoveryAuditViolations(degraded.history), [], '合法降级链零违例');
164
+ assert.ok(recoveryAuditViolations([{ at: T0, kind: 'recovery.attempt', from: 'detected', to: 'recovering', reason: 'x' }]).length > 0, '缺 detected 起点违例');
165
+ assert.ok(recoveryAuditViolations([...recovered.history, { at: T2, kind: 'recovery.attempt', from: 'resolved', to: 'recovering', reason: 'late' }]).length > 0, '终态后事件违例');
166
+ assert.ok(recoveryAuditViolations([{ at: T0, kind: 'recovery.detected', from: 'detected', to: 'detected', reason: 'x', target: ORIGINAL }, { at: T1, kind: 'recovery.resolved', from: 'detected', to: 'resolved', reason: 'swap', target: substituteTarget }]).length > 0, '静默替换(无披露的异目标 resolved)违例');
167
+ assert.ok(recoveryAuditViolations([{ at: T0, kind: 'recovery.detected', from: 'detected', to: 'detected', reason: 'x', target: ORIGINAL }, { at: T1, kind: 'recovery.degraded', from: 'detected', to: 'degraded', reason: 'x' }]).length > 0, '静默降级(无披露)违例');
168
+ assert.ok(recoveryAuditViolations([{ at: T0, kind: 'recovery.detected', from: 'detected', to: 'detected', reason: 'x', target: ORIGINAL }, { at: T1, kind: 'recovery.exploded', from: 'detected', to: 'recovering', reason: 'x' }]).length > 0, '未知事件 kind 违例');
169
+ assert.ok(recoveryAuditViolations([{ at: T0, kind: 'recovery.detected', from: 'detected', to: 'detected', reason: 'x', target: ORIGINAL }, { at: T1, kind: 'recovery.resolved', from: 'detected', to: 'resolved', reason: 'x', target: ORIGINAL }, { at: T2, kind: 'recovery.resolved', from: 'resolved', to: 'resolved', reason: 'double', target: ORIGINAL }]).length > 0, '双重终态违例');
170
+ // ── 8. 纯函数 + 摘要稳定性 ─────────────────────────────────────────────────
171
+ const before = JSON.stringify(afterQuery);
172
+ assert.throws(() => attemptRecoveryStep(afterQuery, { receipt: receipt({}), target: substituteTarget, approach: 'alternative_candidate' }, T2), /recovery_silent_swap_forbidden/);
173
+ assert.equal(JSON.stringify(afterQuery), before, '拒绝即不动(原状态不变)');
174
+ const advanced = attemptRecoveryStep(afterQuery, { receipt: receipt({}), target: ORIGINAL, approach: 'requery_same_hotel' }, T2);
175
+ assert.equal(JSON.stringify(afterQuery), before, '推进返回新状态,原状态不可变');
176
+ assert.notEqual(JSON.stringify(advanced), before);
177
+ const digestA = receiptDigestOf(receipt({}));
178
+ const digestB = receiptDigestOf(receipt({ actionId: 'act-2' }));
179
+ assert.equal(receiptDigestOf(receipt({})), digestA, '同一内容摘要稳定');
180
+ assert.notEqual(digestA, digestB, '内容不同摘要不同');
181
+ assert.match(digestA, /^[0-9a-f]{64}$/, '摘要为 sha256 hex');
182
+ console.log('booking-recovery-chain-tests: all scenarios passed');
@@ -1,7 +1,17 @@
1
+ /**
2
+ * 预订 saga 状态机回归(booking_saga_fsm.v1,issue #17 采纳锚点,run-all §36):
3
+ * 1 字母表/边表封闭性:12 个 (origin,trigger) 组合恰好一边或一拒,无空洞无重复;事件命名收敛
4
+ * 2 触发解析:主路径可达;二次确认与吸收态推进结构化拒绝;解析器与边表/拒绝表十二格全一致
5
+ * 3 审计链校验器:合法路径零违例;缺起点/双确认/补偿后再推进/空 receipt 均被抓出
6
+ * 4 物理对账:真账本(pending_writes 行 + write.* 审计事件)与状态机逐格同归宿——
7
+ * 状态机不是新机器,是 TS-4 saga 基座的具名化(词汇层与物理层分叉即本节红)
8
+ * 5 多租户:saga 键以 tenant_id 为一等字段,跨租户互不可见(ADR-16)
9
+ * 运行(在 ts/ 下):npx tsx scripts/booking-saga-tests.ts
10
+ */
1
11
  import { mkdtempSync, rmSync } from 'node:fs';
2
12
  import { tmpdir } from 'node:os';
3
13
  import { join } from 'node:path';
4
- import { BOOKING_SAGA_SCHEMA, SAGA_EDGES, SAGA_REJECTIONS, SAGA_STATUSES, SAGA_TRIGGER_EVENT, resolveSagaTrigger, sagaLegalPaths, sagaTraceViolations } from '../src/booking-saga.js';
14
+ import { BOOKING_SAGA_SCHEMA, SAGA_EDGES, SAGA_REJECTIONS, SAGA_STATUSES, SAGA_TRIGGER_EVENT, resolveSagaTrigger, sagaLegalPaths, sagaTraceViolations, } from '../src/booking-saga.js';
5
15
  import { ensureLedger } from '../src/state-ledger.js';
6
16
  let pass = 0;
7
17
  let fail = 0;
@@ -9,7 +19,8 @@ function assert(cond, msg) {
9
19
  if (cond) {
10
20
  pass++;
11
21
  console.log(` ok - ${msg}`);
12
- } else {
22
+ }
23
+ else {
13
24
  fail++;
14
25
  console.error(` FAIL - ${msg}`);
15
26
  }
@@ -18,33 +29,30 @@ function parseReceipt(payload) {
18
29
  try {
19
30
  const doc = JSON.parse(payload);
20
31
  return typeof doc.receipt === 'string' && doc.receipt !== '' ? doc.receipt : undefined;
21
- } catch {
32
+ }
33
+ catch {
22
34
  return undefined;
23
35
  }
24
36
  }
25
- const TRIGGERS = [
26
- 'propose',
27
- 'confirm',
28
- 'compensate'
29
- ];
30
- const ORIGINS = [
31
- 'none',
32
- ...SAGA_STATUSES
33
- ];
34
- const COMBOS = ORIGINS.flatMap((from)=>TRIGGERS.map((trigger)=>`${from}:${trigger}`));
35
- const edgeKeys = new Set(SAGA_EDGES.map((e)=>`${e.from}:${e.trigger}`));
36
- const rejectKeys = new Set(SAGA_REJECTIONS.map((e)=>`${e.from}:${e.trigger}`));
37
+ // ---- 1:字母表与边表封闭性 --------------------------------------------------------
38
+ const TRIGGERS = ['propose', 'confirm', 'compensate'];
39
+ const ORIGINS = ['none', ...SAGA_STATUSES];
40
+ const COMBOS = ORIGINS.flatMap(from => TRIGGERS.map(trigger => `${from}:${trigger}`));
41
+ const edgeKeys = new Set(SAGA_EDGES.map(e => `${e.from}:${e.trigger}`));
42
+ const rejectKeys = new Set(SAGA_REJECTIONS.map(e => `${e.from}:${e.trigger}`));
37
43
  assert(BOOKING_SAGA_SCHEMA === 'booking_saga_fsm.v1' && SAGA_STATUSES.join(',') === 'pending,confirmed,compensated', 'schema 词唯一;状态字母表与 pending_writes CHECK 逐字一致');
38
44
  assert(COMBOS.length === 12 && new Set(COMBOS).size === 12 && edgeKeys.size === SAGA_EDGES.length && rejectKeys.size === SAGA_REJECTIONS.length, '12 个 (origin,trigger) 组合;边表与拒绝表内部无重复');
39
- assert(COMBOS.every((k)=>edgeKeys.has(k) !== rejectKeys.has(k)), '每格恰为一条合法边或一条结构化拒绝(全函数,无空洞无第三态)');
40
- assert(SAGA_EDGES.every((e)=>SAGA_STATUSES.includes(e.to) && e.event === SAGA_TRIGGER_EVENT[e.trigger] && e.guard.length > 0), '合法边:终点在字母表内、事件 kind 与触发器一一对应、守卫语义齐全');
41
- const allConsistent = ORIGINS.every((from)=>TRIGGERS.every((trigger)=>{
42
- const v = resolveSagaTrigger(from, trigger);
43
- const edge = SAGA_EDGES.find((e)=>e.from === from && e.trigger === trigger);
44
- if (edge) return v.ok && v.to === edge.to && v.event === edge.event;
45
- return !v.ok && v.reason === SAGA_REJECTIONS.find((x)=>x.from === from && x.trigger === trigger).reason;
46
- }));
45
+ assert(COMBOS.every(k => edgeKeys.has(k) !== rejectKeys.has(k)), '每格恰为一条合法边或一条结构化拒绝(全函数,无空洞无第三态)');
46
+ assert(SAGA_EDGES.every(e => SAGA_STATUSES.includes(e.to) && e.event === SAGA_TRIGGER_EVENT[e.trigger] && e.guard.length > 0), '合法边:终点在字母表内、事件 kind 与触发器一一对应、守卫语义齐全');
47
+ const allConsistent = ORIGINS.every(from => TRIGGERS.every(trigger => {
48
+ const v = resolveSagaTrigger(from, trigger);
49
+ const edge = SAGA_EDGES.find(e => e.from === from && e.trigger === trigger);
50
+ if (edge)
51
+ return v.ok && v.to === edge.to && v.event === edge.event;
52
+ return !v.ok && v.reason === SAGA_REJECTIONS.find(x => x.from === from && x.trigger === trigger).reason;
53
+ }));
47
54
  assert(allConsistent, 'resolveSagaTrigger 十二格与边表/拒绝表全一致(解析器无平行第二逻辑)');
55
+ // ---- 2:主路径、吸收态与审计链校验(纯函数) --------------------------------------
48
56
  {
49
57
  const vProp = resolveSagaTrigger('none', 'propose');
50
58
  const vConf = resolveSagaTrigger('pending', 'confirm');
@@ -57,131 +65,61 @@ assert(allConsistent, 'resolveSagaTrigger 十二格与边表/拒绝表全一致(
57
65
  const vReComp = resolveSagaTrigger('compensated', 'compensate');
58
66
  assert(!vIdem.ok && vIdem.reason === 'idem-exists' && !vRevive.ok && vRevive.reason === 'absorbed-compensated' && !vReComp.ok && vReComp.reason === 'already-compensated', 'compensated 吸收态:propose=幂等存在,confirm/compensate 拒绝(无复活路径)');
59
67
  assert(sagaLegalPaths().length === 4, '合法路径恰四条(主路径及其真前缀)');
60
- const legalClean = sagaLegalPaths().every((path)=>{
61
- const events = path.map((trigger, i)=>({
62
- seq: i + 1,
63
- kind: SAGA_TRIGGER_EVENT[trigger],
64
- receipt: trigger === 'confirm' ? 'PNR-X' : undefined
65
- }));
68
+ const legalClean = sagaLegalPaths().every(path => {
69
+ const events = path.map((trigger, i) => ({ seq: i + 1, kind: SAGA_TRIGGER_EVENT[trigger], receipt: trigger === 'confirm' ? 'PNR-X' : undefined }));
66
70
  return sagaTraceViolations(events).length === 0;
67
71
  });
68
72
  assert(legalClean, '四条合法路径的审计链均零违例');
69
- const flagOf = (events)=>sagaTraceViolations(events)[0];
70
- assert(flagOf([
71
- {
72
- kind: 'write.confirmed',
73
- receipt: 'PNR-A'
74
- }
75
- ]) !== undefined, '缺 pending 起点的 confirm 被审计链抓出');
76
- assert(flagOf([
77
- {
78
- kind: 'write.pending'
79
- },
80
- {
81
- kind: 'write.confirmed',
82
- receipt: 'PNR-A'
83
- },
84
- {
85
- kind: 'write.confirmed',
86
- receipt: 'PNR-B'
87
- }
88
- ]) !== undefined, '双确认被审计链抓出(每键至多确认一次)');
89
- assert(flagOf([
90
- {
91
- kind: 'write.pending'
92
- },
93
- {
94
- kind: 'write.compensated'
95
- },
96
- {
97
- kind: 'write.confirmed',
98
- receipt: 'X'
99
- }
100
- ]) !== undefined, 'compensated 之后不得再有 saga 事件(吸收态)');
101
- assert((flagOf([
102
- {
103
- kind: 'write.pending'
104
- },
105
- {
106
- kind: 'write.confirmed'
107
- }
108
- ]) ?? '').includes('receipt'), 'write.confirmed 空 receipt 被抓(L3 必携外部回执)');
109
- }const root = mkdtempSync(join(tmpdir(), 'gotry-booking-saga-'));
73
+ const flagOf = (events) => sagaTraceViolations(events)[0];
74
+ assert(flagOf([{ kind: 'write.confirmed', receipt: 'PNR-A' }]) !== undefined, '缺 pending 起点的 confirm 被审计链抓出');
75
+ assert(flagOf([{ kind: 'write.pending' }, { kind: 'write.confirmed', receipt: 'PNR-A' }, { kind: 'write.confirmed', receipt: 'PNR-B' }]) !== undefined, '双确认被审计链抓出(每键至多确认一次)');
76
+ assert(flagOf([{ kind: 'write.pending' }, { kind: 'write.compensated' }, { kind: 'write.confirmed', receipt: 'X' }]) !== undefined, 'compensated 之后不得再有 saga 事件(吸收态)');
77
+ assert((flagOf([{ kind: 'write.pending' }, { kind: 'write.confirmed' }]) ?? '').includes('receipt'), 'write.confirmed 空 receipt 被抓(L3 必携外部回执)');
78
+ }
79
+ // ---- 3:物理对账(状态机 = 账本 saga 基座的词汇层,分叉即红) ---------------------
80
+ const root = mkdtempSync(join(tmpdir(), 'gotry-booking-saga-'));
110
81
  try {
111
82
  const ledger = ensureLedger(root);
112
- const sagaEventsOf = (l, key)=>{
83
+ const sagaEventsOf = (l, key) => {
113
84
  const rows = l.db.prepare(`SELECT seq, kind, payload FROM events WHERE tenant_id = ? AND subject_id = ? AND kind IN ('write.pending','write.confirmed','write.compensated') ORDER BY seq`).all(l.tenant, key);
114
- return rows.map((r)=>({
115
- seq: r.seq,
116
- kind: r.kind,
117
- receipt: parseReceipt(r.payload)
118
- }));
85
+ return rows.map(r => ({ seq: r.seq, kind: r.kind, receipt: parseReceipt(r.payload) }));
119
86
  };
120
87
  const K1 = 'booking:K1';
121
- const proposed = ledger.requestPendingWrite({
122
- idemKey: K1,
123
- seam: 'flight-order-confirm',
124
- payload: {
125
- flight: 'MU123'
126
- }
127
- });
88
+ const proposed = ledger.requestPendingWrite({ idemKey: K1, seam: 'flight-order-confirm', payload: { flight: 'MU123' } });
128
89
  assert(proposed.created === true && proposed.status === 'pending' && resolveSagaTrigger('none', 'propose').to === 'pending', '物理提议 ∅→pending 与状态机同归宿(L2 只登记不执行)');
129
- const dup = ledger.requestPendingWrite({
130
- idemKey: K1,
131
- seam: 'flight-order-confirm',
132
- payload: {
133
- flight: 'MU123'
134
- }
135
- });
90
+ const dup = ledger.requestPendingWrite({ idemKey: K1, seam: 'flight-order-confirm', payload: { flight: 'MU123' } });
136
91
  assert(dup.created === false && dup.status === 'pending' && resolveSagaTrigger('pending', 'propose').reason === 'idem-exists', '同幂等键重复提议:账本 no-op = 状态机 idem-exists');
137
92
  const confirmed = ledger.confirmPendingWrite(K1, 'PNR-A');
138
93
  assert(confirmed.ok === true && confirmed.status === 'confirmed' && resolveSagaTrigger('pending', 'confirm').to === 'confirmed', 'L3 具名确认 pending→confirmed(携 receipt)');
139
94
  const reConfirmed = ledger.confirmPendingWrite(K1, 'PNR-SHOULD-IGNORE');
140
- assert(reConfirmed.ok === false && (ledger.listPendingWrites().find((w)=>w.idem_key === K1)?.receipt ?? null) === 'PNR-A' && resolveSagaTrigger('confirmed', 'confirm').reason === 'already-confirmed', '二次确认被账本守卫拒绝且 receipt 不可变(仍为 PNR-A)');
95
+ assert(reConfirmed.ok === false && (ledger.listPendingWrites().find(w => w.idem_key === K1)?.receipt ?? null) === 'PNR-A' && resolveSagaTrigger('confirmed', 'confirm').reason === 'already-confirmed', '二次确认被账本守卫拒绝且 receipt 不可变(仍为 PNR-A)');
141
96
  const compensated = ledger.compensatePendingWrite(K1, '用户改签退款');
142
- assert(compensated.ok === true && ledger.listPendingWrites().find((w)=>w.idem_key === K1)?.status === 'compensated', '已确认副作用可 saga 补偿(pending|confirmed → compensated)');
97
+ assert(compensated.ok === true && ledger.listPendingWrites().find(w => w.idem_key === K1)?.status === 'compensated', '已确认副作用可 saga 补偿(pending|confirmed → compensated)');
143
98
  const afterConfirm = ledger.confirmPendingWrite(K1, 'PNR-POST');
144
99
  const afterCompensate = ledger.compensatePendingWrite(K1, '重复补偿');
145
- const afterPropose = ledger.requestPendingWrite({
146
- idemKey: K1,
147
- seam: 'flight-order-confirm',
148
- payload: {}
149
- });
100
+ const afterPropose = ledger.requestPendingWrite({ idemKey: K1, seam: 'flight-order-confirm', payload: {} });
150
101
  assert(afterConfirm.ok === false && afterCompensate.ok === false && afterPropose.created === false, '吸收态:compensated 后 confirm/compensate/propose 全拒,零复活');
151
102
  const audit = sagaEventsOf(ledger, K1);
152
- assert(audit.map((e)=>e.kind).join(',') === 'write.pending,write.confirmed,write.compensated', '审计事件链与边表路径同构(被拒调用零事件)');
103
+ assert(audit.map(e => e.kind).join(',') === 'write.pending,write.confirmed,write.compensated', '审计事件链与边表路径同构(被拒调用零事件)');
153
104
  assert(sagaTraceViolations(audit).length === 0, '真实账本审计链过词汇层校验零违例');
154
105
  const K3 = 'booking:K3';
155
- ledger.requestPendingWrite({
156
- idemKey: K3,
157
- seam: 'hotel-order-confirm',
158
- payload: {}
159
- });
106
+ ledger.requestPendingWrite({ idemKey: K3, seam: 'hotel-order-confirm', payload: {} });
160
107
  const emptyReceipt = ledger.confirmPendingWrite(K3, '');
161
- assert(emptyReceipt.ok === true && sagaTraceViolations(sagaEventsOf(ledger, K3)).some((v)=>v.includes('receipt')), '已知边界:账本物理层暂不挡空 receipt,词汇层审计链抓出(物理 CHECK 随 M5 拍板入 schema)');
108
+ assert(emptyReceipt.ok === true && sagaTraceViolations(sagaEventsOf(ledger, K3)).some(v => v.includes('receipt')), '已知边界:账本物理层暂不挡空 receipt,词汇层审计链抓出(物理 CHECK 随 M5 拍板入 schema)');
162
109
  const other = ensureLedger(root, 'tenant-b');
163
- const crossProp = other.requestPendingWrite({
164
- idemKey: K1,
165
- seam: 'flight-order-confirm',
166
- payload: {}
167
- });
110
+ const crossProp = other.requestPendingWrite({ idemKey: K1, seam: 'flight-order-confirm', payload: {} });
168
111
  assert(crossProp.created === true, '同 idem_key 跨租户互不可见:tenant-b 可建自己的 K1(tenant_id 一等字段,ADR-16)');
112
+ const sagaRows = other.db.prepare('SELECT tenant_id, idem_key, status FROM pending_writes WHERE idem_key = ? ORDER BY tenant_id').all(K1);
113
+ assert(sagaRows.map(r => `${r.tenant_id}:${r.status}`).join(',') === 'local:compensated,tenant-b:pending', '同 saga idem_key 的 pending_writes 行按 tenant 绑定,tenant-b 不覆盖 local');
114
+ assert(sagaEventsOf(other, K1).map(e => e.kind).join(',') === 'write.pending' && other.readEvents('write.pending', 10).every(e => e.tenant_id === 'tenant-b'), 'tenant-b saga 审计事件 owner 不回落 local,readEvents 不串租户');
169
115
  const LOCAL_ONLY = 'booking:local-only';
170
- ledger.requestPendingWrite({
171
- idemKey: LOCAL_ONLY,
172
- seam: 'flight-order-confirm',
173
- payload: {}
174
- });
116
+ ledger.requestPendingWrite({ idemKey: LOCAL_ONLY, seam: 'flight-order-confirm', payload: {} });
175
117
  const crossMissing = other.confirmPendingWrite(LOCAL_ONLY, 'PNR-CROSS');
176
118
  assert(crossMissing.ok === false && crossMissing.status === 'missing', 'tenant-b 看不到 local 的 saga 键(拒绝 missing)');
177
- } finally{
178
- rmSync(root, {
179
- recursive: true,
180
- force: true
181
- });
119
+ }
120
+ finally {
121
+ rmSync(root, { recursive: true, force: true });
182
122
  }
183
123
  console.log(`\nBOOKING SAGA TESTS: ${pass} ok, ${fail} fail`);
184
- if (fail > 0) process.exit(1);
185
-
186
-
187
- //# sourceURL=ts/scripts/booking-saga-tests.ts
124
+ if (fail > 0)
125
+ process.exit(1);