@danceiny/gotry 0.0.1-rc.9 → 0.2.0-rc.28

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 (533) hide show
  1. package/README.md +172 -219
  2. package/README.zh-CN.md +222 -0
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-booking-copilot.js +53 -0
  5. package/bin/gotry-bootstrap.js +1788 -0
  6. package/bin/gotry-inner.js +733 -83
  7. package/bin/gotry-process-liveness.d.ts +27 -0
  8. package/bin/gotry-process-liveness.js +97 -0
  9. package/bin/gotry-runtime-resolution.d.ts +27 -0
  10. package/bin/gotry-runtime-resolution.js +49 -0
  11. package/bin/gotry.js +1 -1
  12. package/client/client.js +203 -0
  13. package/cordis.gotry-patch.yml +80 -56
  14. package/data/airline-airports.json +39 -0
  15. package/data/stations-12306-verify.json +524 -0
  16. package/dist/capabilities/agent-reach-deep.js +45 -122
  17. package/dist/capabilities/agent-reach.js +101 -146
  18. package/dist/capabilities/anything.js +161 -94
  19. package/dist/capabilities/artifacts.js +370 -0
  20. package/dist/capabilities/channel-health.js +131 -0
  21. package/dist/capabilities/channel-registry.js +256 -0
  22. package/dist/capabilities/doctor.js +553 -0
  23. package/dist/capabilities/effect.js +394 -0
  24. package/dist/capabilities/external-event.js +149 -0
  25. package/dist/capabilities/fact-log.js +55 -0
  26. package/dist/capabilities/flyai-config.js +337 -0
  27. package/dist/capabilities/flyai.js +947 -110
  28. package/dist/capabilities/fx-contract.js +293 -0
  29. package/dist/capabilities/geo-atlas.js +246 -0
  30. package/dist/capabilities/ground-transfer.js +676 -0
  31. package/dist/capabilities/hbcli.js +260 -123
  32. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  33. package/dist/capabilities/incident-log.js +131 -61
  34. package/dist/capabilities/itinerary-artifact.js +190 -0
  35. package/dist/capabilities/itinerary-deck-artifact.js +194 -0
  36. package/dist/capabilities/itinerary-deck-export.js +339 -0
  37. package/dist/capabilities/lavish-local.js +924 -0
  38. package/dist/capabilities/model-override.js +38 -0
  39. package/dist/capabilities/opensky.js +54 -43
  40. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  41. package/dist/capabilities/resilience.js +191 -0
  42. package/dist/capabilities/route-provider-conformance.js +341 -0
  43. package/dist/capabilities/session/action-cache.js +50 -44
  44. package/dist/capabilities/session/adapters/ctrip-flight.js +120 -62
  45. package/dist/capabilities/session/adapters/ctrip-hotel.js +222 -0
  46. package/dist/capabilities/session/adapters/dida-portal.js +274 -0
  47. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  48. package/dist/capabilities/session/adapters/rail-12306.js +342 -0
  49. package/dist/capabilities/session/benchmark.js +189 -0
  50. package/dist/capabilities/session/bridge-ledger.js +224 -0
  51. package/dist/capabilities/session/extension-bridge.js +636 -0
  52. package/dist/capabilities/session/extension-channel.js +109 -0
  53. package/dist/capabilities/session/extension-distribution.js +230 -0
  54. package/dist/capabilities/session/extract.js +31 -21
  55. package/dist/capabilities/session/golden-score.js +114 -0
  56. package/dist/capabilities/session/health-watch.js +215 -0
  57. package/dist/capabilities/session/read-guard.js +75 -24
  58. package/dist/capabilities/session/static-flight-golden.js +150 -0
  59. package/dist/capabilities/session/transport.js +97 -83
  60. package/dist/capabilities/session/wizard.js +117 -0
  61. package/dist/capabilities/session-consent.js +178 -0
  62. package/dist/capabilities/session-login.js +170 -0
  63. package/dist/capabilities/session-search.js +635 -44
  64. package/dist/capabilities/spawn-bounded.js +203 -0
  65. package/dist/capabilities/sponsor-plugin.js +341 -0
  66. package/dist/capabilities/visa-policy.js +140 -0
  67. package/dist/capabilities/weather.js +243 -138
  68. package/dist/data/session-golden-20.json +25 -0
  69. package/dist/data/sf-golden-manifest.json +103 -0
  70. package/dist/data/sf-static-routes.json +91 -0
  71. package/dist/scripts/action-cache-tests.js +24 -59
  72. package/dist/scripts/agent-planning-turn-deadline-e2e.js +242 -0
  73. package/dist/scripts/agent-planning-turn-deadline-tests.js +185 -0
  74. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  75. package/dist/scripts/agent-reach-error-tests.js +354 -0
  76. package/dist/scripts/agent-reach-tests.js +26 -18
  77. package/dist/scripts/agent-reach-wrapper-tests.js +50 -50
  78. package/dist/scripts/anything-path-measure-tests.js +133 -0
  79. package/dist/scripts/anything-path-measure.js +150 -0
  80. package/dist/scripts/anything-tests.js +250 -60
  81. package/dist/scripts/artifact-client-contract-tests.js +220 -0
  82. package/dist/scripts/artifacts-capability-tests.js +727 -0
  83. package/dist/scripts/async-collect.js +50 -11
  84. package/dist/scripts/benchmark-environment-bridge-e2e.js +741 -0
  85. package/dist/scripts/benchmark-environment-bridge-tests.js +1452 -0
  86. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +126 -0
  87. package/dist/scripts/booking-copilot-availability-policy-tests.js +265 -0
  88. package/dist/scripts/booking-copilot-bin-proof-tests.js +66 -0
  89. package/dist/scripts/booking-copilot-cleanup-proof-helper.js +76 -0
  90. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +144 -0
  91. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +424 -0
  92. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +2839 -0
  93. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +309 -0
  94. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +238 -0
  95. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +126 -0
  96. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +156 -0
  97. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +70 -0
  98. package/dist/scripts/booking-copilot-intent-proof-tests.js +422 -0
  99. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +184 -0
  100. package/dist/scripts/booking-copilot-planner-live-probe.js +95 -0
  101. package/dist/scripts/booking-copilot-proof-runtime.js +42 -0
  102. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +210 -0
  103. package/dist/scripts/booking-copilot-runtime-proof-tests.js +2349 -0
  104. package/dist/scripts/booking-copilot-server-proof-tests.js +784 -0
  105. package/dist/scripts/booking-copilot-startup-proof-tests.js +94 -0
  106. package/dist/scripts/booking-executor-tests.js +80 -0
  107. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  108. package/dist/scripts/booking-saga-tests.js +125 -0
  109. package/dist/scripts/booking-surface-contract-proof-tests.js +212 -0
  110. package/dist/scripts/bootstrap-tests.js +1549 -0
  111. package/dist/scripts/bridge-events-tests.js +239 -0
  112. package/dist/scripts/bridge-ledger-tests.js +428 -0
  113. package/dist/scripts/build-changelog.js +238 -0
  114. package/dist/scripts/build-metrics-report.js +331 -0
  115. package/dist/scripts/changelog-tests.js +118 -0
  116. package/dist/scripts/channel-probe-tests.js +103 -0
  117. package/dist/scripts/channel-probe.js +169 -0
  118. package/dist/scripts/channel-registry-tests.js +208 -0
  119. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  120. package/dist/scripts/companion-tests.js +25 -70
  121. package/dist/scripts/dida-cookie-probe.js +87 -0
  122. package/dist/scripts/dida-runner-stop-tests.js +311 -0
  123. package/dist/scripts/dida-sw-probe.js +114 -0
  124. package/dist/scripts/diff-test.js +17 -12
  125. package/dist/scripts/doctor-tests.js +600 -0
  126. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  127. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  128. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  129. package/dist/scripts/dsh-artifact-e2e.js +279 -0
  130. package/dist/scripts/dsh-artifact-web-e2e.js +675 -0
  131. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  132. package/dist/scripts/dsh-llm-tests.js +437 -0
  133. package/dist/scripts/dsh-runtime-closure-tests.js +182 -0
  134. package/dist/scripts/dsh-runtime-closure.js +162 -0
  135. package/dist/scripts/dsh-session-v3-migration-proof.js +109 -0
  136. package/dist/scripts/dsh-subprocess-local-proof.js +150 -0
  137. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  138. package/dist/scripts/effect-tests.js +539 -0
  139. package/dist/scripts/engine-run.js +1 -3
  140. package/dist/scripts/engine-tests.js +16 -10
  141. package/dist/scripts/evaluation-cadence-tests.js +174 -0
  142. package/dist/scripts/evaluation-contract-tests.js +268 -0
  143. package/dist/scripts/extension-distribution-cli.js +39 -0
  144. package/dist/scripts/extension-distribution-tests.js +443 -0
  145. package/dist/scripts/extension-tests.js +988 -0
  146. package/dist/scripts/external-event-tests.js +350 -0
  147. package/dist/scripts/fact-gate-tests.js +869 -0
  148. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  149. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  150. package/dist/scripts/flight-malformed-tests.js +159 -0
  151. package/dist/scripts/flyai-setup-tests.js +228 -0
  152. package/dist/scripts/flyai-setup-tool-tests.js +54 -0
  153. package/dist/scripts/flyai-tests.js +692 -0
  154. package/dist/scripts/flyai-tool-registration-contract-tests.js +91 -0
  155. package/dist/scripts/fx-contract-tests.js +201 -0
  156. package/dist/scripts/g5-guard-tests.js +150 -0
  157. package/dist/scripts/g5-guard.js +193 -0
  158. package/dist/scripts/gen-lunar-anchors.js +106 -0
  159. package/dist/scripts/geo-atlas-tests.js +283 -0
  160. package/dist/scripts/gotry-backend-kernel-tests.js +206 -0
  161. package/dist/scripts/gotry-try-demo-tests.js +79 -0
  162. package/dist/scripts/gotry-try-demo.js +49 -0
  163. package/dist/scripts/ground-transfer-tests.js +956 -0
  164. package/dist/scripts/hbcli-e2e-tests.js +159 -0
  165. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  166. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  167. package/dist/scripts/hbcli-tests.js +121 -69
  168. package/dist/scripts/health-watch-cli.js +73 -0
  169. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  170. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  171. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  172. package/dist/scripts/i18n-tests.js +41 -0
  173. package/dist/scripts/incident-tests.js +281 -87
  174. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  175. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  176. package/dist/scripts/issue-271-liveness-tests.js +569 -0
  177. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  178. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  179. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  180. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  181. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  182. package/dist/scripts/issue436-persisted-routing-e2e.js +247 -0
  183. package/dist/scripts/itinerary-artifact-tests.js +368 -0
  184. package/dist/scripts/itinerary-deck-artifact-tests.js +416 -0
  185. package/dist/scripts/itinerary-deck-export-tests.js +552 -0
  186. package/dist/scripts/itinerary-deck-tests.js +435 -0
  187. package/dist/scripts/itinerary-html-tests.js +426 -0
  188. package/dist/scripts/journey-tests.js +22 -27
  189. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  190. package/dist/scripts/kernel-manifest-gate.js +126 -0
  191. package/dist/scripts/kernel-manifest-tests.js +212 -0
  192. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  193. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  194. package/dist/scripts/kernel-manifest.js +369 -0
  195. package/dist/scripts/lavish-local-tests.js +1226 -0
  196. package/dist/scripts/lavish-product-tools-tests.js +537 -0
  197. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  198. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  199. package/dist/scripts/ledger-tests.js +382 -246
  200. package/dist/scripts/ledger-workflow-crash.js +10 -20
  201. package/dist/scripts/m3-cohort-tests.js +714 -0
  202. package/dist/scripts/m3-cohort.js +310 -0
  203. package/dist/scripts/managed-dsh-cleanup-diagnostic-proof.js +184 -0
  204. package/dist/scripts/managed-dsh-close-failure-proof.js +172 -0
  205. package/dist/scripts/managed-dsh-run-port-proof.js +153 -0
  206. package/dist/scripts/managed-dsh-terminal-proof.js +252 -0
  207. package/dist/scripts/map-tools-vendor-package-proof.js +365 -0
  208. package/dist/scripts/memory-capture-tests.js +242 -61
  209. package/dist/scripts/memory-decay-tests.js +18 -52
  210. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  211. package/dist/scripts/memory-lifecycle.js +196 -0
  212. package/dist/scripts/memory-metrics.js +24 -12
  213. package/dist/scripts/memory-value-report.js +631 -0
  214. package/dist/scripts/memory-value-tests.js +328 -0
  215. package/dist/scripts/metrics-report-tests.js +151 -0
  216. package/dist/scripts/model-override-e2e.js +137 -0
  217. package/dist/scripts/nightly-evidence-tests.js +203 -0
  218. package/dist/scripts/nightly-evidence.js +303 -0
  219. package/dist/scripts/nudge-digest.js +39 -28
  220. package/dist/scripts/observability-tests.js +381 -0
  221. package/dist/scripts/onboarding-tests.js +161 -0
  222. package/dist/scripts/opensky-check.js +18 -13
  223. package/dist/scripts/opensky-tests.js +28 -28
  224. package/dist/scripts/outcome-projection-tests.js +427 -0
  225. package/dist/scripts/persona-sim-tests.js +603 -0
  226. package/dist/scripts/persona-sim.js +1081 -0
  227. package/dist/scripts/persona-surface-guard-tests.js +49 -0
  228. package/dist/scripts/pnpm-dsh-closure-proof.js +17 -0
  229. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  230. package/dist/scripts/price-drift-tests.js +340 -0
  231. package/dist/scripts/price-drift-watch.js +447 -0
  232. package/dist/scripts/probe-poi-tests.js +53 -45
  233. package/dist/scripts/product-metrics.js +483 -0
  234. package/dist/scripts/publish-preverify.js +110 -0
  235. package/dist/scripts/realtime-pricing-tests.js +76 -0
  236. package/dist/scripts/recall-tests.js +513 -0
  237. package/dist/scripts/replay-async.js +22 -18
  238. package/dist/scripts/replay-real.js +18 -18
  239. package/dist/scripts/replay.js +54 -82
  240. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  241. package/dist/scripts/session-attach-diagnose.js +20 -20
  242. package/dist/scripts/session-attach-poc.js +34 -37
  243. package/dist/scripts/session-benchmark.js +180 -0
  244. package/dist/scripts/session-dida-live-e2e.js +199 -0
  245. package/dist/scripts/session-extract-tests.js +14 -35
  246. package/dist/scripts/session-link-tests.js +478 -0
  247. package/dist/scripts/session-login.js +27 -46
  248. package/dist/scripts/session-tests.js +796 -96
  249. package/dist/scripts/session-zone-crash.js +49 -0
  250. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  251. package/dist/scripts/session-zone-observation-tests.js +492 -0
  252. package/dist/scripts/session-zone-observe.js +135 -0
  253. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  254. package/dist/scripts/session-zones-tests.js +374 -0
  255. package/dist/scripts/sf-live-benchmark.js +367 -0
  256. package/dist/scripts/sf-live-challenge-stop-tests.js +453 -0
  257. package/dist/scripts/sf-live-cli-tests.js +92 -0
  258. package/dist/scripts/sf-live-options.js +46 -0
  259. package/dist/scripts/sf-manifest-tests.js +30 -0
  260. package/dist/scripts/sf-manifest.js +33 -0
  261. package/dist/scripts/sf-soft-score-tests.js +67 -0
  262. package/dist/scripts/sf-summary-tests.js +324 -0
  263. package/dist/scripts/sf-summary.js +525 -0
  264. package/dist/scripts/share-tests.js +432 -0
  265. package/dist/scripts/skeleton-check.js +16 -14
  266. package/dist/scripts/skeleton-integration-test.js +28 -11
  267. package/dist/scripts/skills-contract-tests.js +51 -46
  268. package/dist/scripts/smoke-session-gate-tests.js +21 -0
  269. package/dist/scripts/smoke.js +690 -246
  270. package/dist/scripts/spawn-bounded-abort-tests.js +244 -0
  271. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  272. package/dist/scripts/state-cli-tests.js +226 -101
  273. package/dist/scripts/state-cli.js +565 -198
  274. package/dist/scripts/state-repair-cli.js +66 -0
  275. package/dist/scripts/state-repair-tests.js +140 -0
  276. package/dist/scripts/static-golden-tests.js +165 -0
  277. package/dist/scripts/time-eval-tests.js +212 -192
  278. package/dist/scripts/travel-timeline-tests.js +29 -93
  279. package/dist/scripts/turn-handoff-collect-tests.js +169 -0
  280. package/dist/scripts/turn-handoff-collect.js +161 -0
  281. package/dist/scripts/turn-policy-tests.js +55 -0
  282. package/dist/scripts/typed-contract-canary.js +176 -0
  283. package/dist/scripts/tz-resolver-sanity.js +50 -0
  284. package/dist/scripts/tz-resolver-tests.js +584 -0
  285. package/dist/scripts/unified-tests.js +60 -20
  286. package/dist/scripts/visa-policy-tests.js +62 -0
  287. package/dist/scripts/weather-tests.js +276 -41
  288. package/dist/scripts/wish-channel-gate-tests.js +54 -0
  289. package/dist/scripts/write-gate-tests.js +522 -0
  290. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  291. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  292. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  293. package/dist/scripts/z3-race-tests.js +46 -0
  294. package/dist/src/artifact-gate.js +829 -0
  295. package/dist/src/backend/kernel.js +91 -0
  296. package/dist/src/backend/modules/booking-copilot.js +92 -0
  297. package/dist/src/backend/modules/booking-executor.js +124 -0
  298. package/dist/src/backend/modules/session-search.js +551 -0
  299. package/dist/src/benchmark-agent-conformance.js +578 -0
  300. package/dist/src/benchmark-environment-bridge.js +515 -0
  301. package/dist/src/benchmark-headless-child-diagnostics.js +166 -0
  302. package/dist/src/benchmark-tool-isolation.js +151 -0
  303. package/dist/src/bookable-facts.js +533 -0
  304. package/dist/src/booking-saga.js +97 -0
  305. package/dist/src/booking-surface/availability-policy.js +697 -0
  306. package/dist/src/booking-surface/booking-intent.js +43 -0
  307. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  308. package/dist/src/booking-surface/canonical-schema.js +116 -0
  309. package/dist/src/booking-surface/contracts.js +75 -0
  310. package/dist/src/booking-surface/dsh-planner.js +1854 -0
  311. package/dist/src/booking-surface/dsh-plugin.js +378 -0
  312. package/dist/src/booking-surface/dsh-worker.js +98 -0
  313. package/dist/src/booking-surface/error-codes.js +231 -0
  314. package/dist/src/booking-surface/index.js +9 -0
  315. package/dist/src/booking-surface/managed-dsh-cleanup-diagnostic.js +97 -0
  316. package/dist/src/booking-surface/managed-dsh-run-port.js +244 -0
  317. package/dist/src/booking-surface/profile.js +24 -0
  318. package/dist/src/booking-surface/recovery-chain.js +367 -0
  319. package/dist/src/booking-surface/runtime.js +1968 -0
  320. package/dist/src/booking-surface/server.js +674 -0
  321. package/dist/src/booking-surface/startup.js +168 -0
  322. package/dist/src/booking-surface/validation.js +261 -0
  323. package/dist/src/bridge.js +20 -13
  324. package/dist/src/city-scenario-tier.js +378 -0
  325. package/dist/src/companions.js +48 -74
  326. package/dist/src/contracts.js +21 -43
  327. package/dist/src/dsh-llm.js +171 -115
  328. package/dist/src/engine.js +276 -193
  329. package/dist/src/evaluation-cadence.js +97 -0
  330. package/dist/src/evaluation-contracts.js +283 -0
  331. package/dist/src/flight-pack-adapter.js +54 -0
  332. package/dist/src/flight-pack-contract.js +2 -0
  333. package/dist/src/flyai-setup-tool.js +70 -0
  334. package/dist/src/gotry-backend.js +125 -0
  335. package/dist/src/hotel-date-gate.js +118 -0
  336. package/dist/src/i18n.js +88 -0
  337. package/dist/src/index.js +2385 -881
  338. package/dist/src/itinerary-deck.js +139 -0
  339. package/dist/src/itinerary-doc-shared.js +661 -0
  340. package/dist/src/itinerary-html.js +131 -0
  341. package/dist/src/journey.js +155 -108
  342. package/dist/src/lavish-tools.js +400 -0
  343. package/dist/src/ledger-repair-apply.js +538 -0
  344. package/dist/src/ledger-repair-plan.js +463 -0
  345. package/dist/src/loop.js +331 -161
  346. package/dist/src/m3-cohort.js +1577 -0
  347. package/dist/src/memory-capture.js +180 -32
  348. package/dist/src/memory-decay.js +30 -14
  349. package/dist/src/memory-lifecycle.js +1310 -0
  350. package/dist/src/memory-utility.js +27 -40
  351. package/dist/src/mock-llm.js +36 -61
  352. package/dist/src/model.js +81 -36
  353. package/dist/src/outcome-projection.js +746 -0
  354. package/dist/src/realtime-pricing.js +103 -0
  355. package/dist/src/recall/card.js +74 -0
  356. package/dist/src/recall/evaluator.js +140 -0
  357. package/dist/src/recall/integration.js +17 -0
  358. package/dist/src/recall/tick.js +166 -0
  359. package/dist/src/session-link/plan-it-action.js +48 -0
  360. package/dist/src/session-link/session-link.js +248 -0
  361. package/dist/src/session-zone-ledger.js +312 -0
  362. package/dist/src/session-zone-observation.js +354 -0
  363. package/dist/src/session-zone-wiring.js +658 -0
  364. package/dist/src/session-zones.js +510 -0
  365. package/dist/src/share/adapters.js +51 -0
  366. package/dist/src/share/share-consent.js +76 -0
  367. package/dist/src/share/share-deck.js +58 -0
  368. package/dist/src/share/share-token.js +146 -0
  369. package/dist/src/slot-spec.js +84 -106
  370. package/dist/src/state-ledger.js +696 -524
  371. package/dist/src/state-repair.js +299 -0
  372. package/dist/src/subagent-job-id-guard.js +58 -0
  373. package/dist/src/time-anchor.js +231 -33
  374. package/dist/src/tool-packet.js +20 -8
  375. package/dist/src/travel-slots.js +58 -35
  376. package/dist/src/travel-timeline.js +44 -63
  377. package/dist/src/turn-deadline.js +287 -0
  378. package/dist/src/turn-policy.js +118 -0
  379. package/dist/src/tz-resolver.js +143 -0
  380. package/dist/src/unified.js +600 -360
  381. package/dist/src/wish-pool.js +42 -12
  382. package/dist/src/write-gate.js +495 -0
  383. package/dist/src/z3-shared.js +284 -0
  384. package/extension/README.md +21 -0
  385. package/extension/README.zh-CN.md +21 -0
  386. package/extension/background.js +463 -0
  387. package/extension/content-bridge.js +270 -0
  388. package/extension/content-main.js +111 -0
  389. package/extension/manifest.json +60 -0
  390. package/package.json +407 -14
  391. package/schemas/booking.intent.schema.json +193 -0
  392. package/schemas/booking.surface.schema.json +2622 -0
  393. package/ts/capabilities/agent-reach-deep.ts +114 -0
  394. package/ts/capabilities/agent-reach.ts +198 -0
  395. package/ts/capabilities/anything.ts +282 -0
  396. package/ts/capabilities/artifacts.ts +428 -0
  397. package/ts/capabilities/fact-log.ts +54 -0
  398. package/ts/capabilities/flyai-config.ts +388 -0
  399. package/ts/capabilities/flyai.ts +1274 -0
  400. package/ts/capabilities/hbcli.ts +253 -71
  401. package/ts/capabilities/incident-log.ts +88 -59
  402. package/ts/capabilities/itinerary-artifact.ts +214 -0
  403. package/ts/capabilities/itinerary-deck-artifact.ts +218 -0
  404. package/ts/capabilities/itinerary-deck-export.ts +378 -0
  405. package/ts/capabilities/opensky.ts +121 -0
  406. package/ts/capabilities/session/action-cache.ts +148 -0
  407. package/ts/capabilities/session/adapters/ctrip-flight.ts +181 -0
  408. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  409. package/ts/capabilities/session/adapters/dida-portal.ts +338 -0
  410. package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
  411. package/ts/capabilities/session/adapters/rail-12306.ts +392 -0
  412. package/ts/capabilities/session/benchmark.ts +276 -0
  413. package/ts/capabilities/session/bridge-ledger.ts +333 -0
  414. package/ts/capabilities/session/extension-bridge.ts +794 -0
  415. package/ts/capabilities/session/extension-channel.ts +144 -0
  416. package/ts/capabilities/session/extension-distribution.ts +266 -0
  417. package/ts/capabilities/session/extract.ts +54 -0
  418. package/ts/capabilities/session/golden-score.ts +139 -0
  419. package/ts/capabilities/session/health-watch.ts +264 -0
  420. package/ts/capabilities/session/read-guard.ts +141 -0
  421. package/ts/capabilities/session/static-flight-golden.ts +209 -0
  422. package/ts/capabilities/session/transport.ts +146 -0
  423. package/ts/capabilities/session/wizard.ts +140 -0
  424. package/ts/capabilities/session-consent.ts +211 -0
  425. package/ts/capabilities/session-login.ts +208 -0
  426. package/ts/capabilities/session-search.ts +893 -0
  427. package/ts/capabilities/weather.ts +330 -0
  428. package/ts/dsh-runtime/vendor/README.md +68 -0
  429. package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
  430. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +405 -0
  431. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +404 -0
  432. package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +1584 -0
  433. package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
  434. package/ts/dsh-runtime/vendor/dsh-map-tools/icon.svg +5 -0
  435. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +595 -0
  436. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
  437. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +58 -0
  438. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
  439. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
  440. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +131 -0
  441. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
  442. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/geo.js +232 -0
  443. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +120 -0
  444. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +40 -0
  445. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/staticmap-route.js +115 -0
  446. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
  447. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
  448. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +267 -0
  449. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +90 -0
  450. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
  451. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
  452. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
  453. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
  454. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +23 -0
  455. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
  456. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/geo.d.ts +61 -0
  457. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
  458. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
  459. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/staticmap-route.d.ts +31 -0
  460. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
  461. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
  462. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +75 -0
  463. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +68 -0
  464. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
  465. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +97 -0
  466. package/ts/package.json +293 -3
  467. package/ts/scripts/async-collect.ts +91 -0
  468. package/ts/scripts/map-tools-vendor-package-proof.ts +404 -0
  469. package/ts/scripts/skeleton-check.ts +1 -1
  470. package/ts/scripts/skeleton-integration-test.ts +23 -1
  471. package/ts/scripts/state-cli.ts +586 -0
  472. package/ts/scripts/turn-handoff-collect.ts +178 -0
  473. package/ts/src/artifact-gate.ts +991 -0
  474. package/ts/src/benchmark-agent-conformance.ts +619 -0
  475. package/ts/src/benchmark-environment-bridge.ts +538 -0
  476. package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
  477. package/ts/src/benchmark-tool-isolation.ts +166 -0
  478. package/ts/src/bookable-facts.ts +837 -0
  479. package/ts/src/booking-saga.ts +133 -0
  480. package/ts/src/booking-surface/availability-policy.ts +522 -0
  481. package/ts/src/booking-surface/booking-intent.ts +86 -0
  482. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  483. package/ts/src/booking-surface/canonical-schema.js +116 -0
  484. package/ts/src/booking-surface/contracts.ts +475 -0
  485. package/ts/src/booking-surface/dsh-planner.ts +1949 -0
  486. package/ts/src/booking-surface/dsh-plugin.js +378 -0
  487. package/ts/src/booking-surface/dsh-worker.ts +110 -0
  488. package/ts/src/booking-surface/error-codes.ts +241 -0
  489. package/ts/src/booking-surface/index.ts +9 -0
  490. package/ts/src/booking-surface/managed-dsh-cleanup-diagnostic.ts +151 -0
  491. package/ts/src/booking-surface/managed-dsh-run-port.ts +273 -0
  492. package/ts/src/booking-surface/profile.ts +42 -0
  493. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  494. package/ts/src/booking-surface/runtime.ts +1793 -0
  495. package/ts/src/booking-surface/server.ts +733 -0
  496. package/ts/src/booking-surface/startup.ts +201 -0
  497. package/ts/src/booking-surface/validation.ts +217 -0
  498. package/ts/src/companions.ts +113 -0
  499. package/ts/src/contracts.ts +17 -3
  500. package/ts/src/dsh-llm.ts +152 -17
  501. package/ts/src/flyai-setup-tool.ts +77 -0
  502. package/ts/src/index.ts +2123 -200
  503. package/ts/src/itinerary-deck.ts +189 -0
  504. package/ts/src/itinerary-doc-shared.ts +741 -0
  505. package/ts/src/itinerary-html.ts +171 -0
  506. package/ts/src/loop.ts +234 -26
  507. package/ts/src/memory-capture.ts +283 -0
  508. package/ts/src/memory-decay.ts +55 -0
  509. package/ts/src/memory-utility.ts +70 -0
  510. package/ts/src/mock-llm.ts +4 -8
  511. package/ts/src/model.ts +88 -2
  512. package/ts/src/recall/card.ts +106 -0
  513. package/ts/src/recall/evaluator.ts +170 -0
  514. package/ts/src/recall/integration.ts +35 -0
  515. package/ts/src/recall/tick.ts +213 -0
  516. package/ts/src/session-link/plan-it-action.ts +78 -0
  517. package/ts/src/session-link/session-link.ts +275 -0
  518. package/ts/src/share/adapters.ts +93 -0
  519. package/ts/src/share/share-consent.ts +105 -0
  520. package/ts/src/share/share-deck.ts +85 -0
  521. package/ts/src/share/share-token.ts +166 -0
  522. package/ts/src/slot-spec.ts +172 -0
  523. package/ts/src/state-ledger.ts +1166 -0
  524. package/ts/src/subagent-job-id-guard.ts +72 -0
  525. package/ts/src/time-anchor.ts +338 -0
  526. package/ts/src/tool-packet.ts +32 -0
  527. package/ts/src/travel-slots.ts +219 -0
  528. package/ts/src/travel-timeline.ts +83 -0
  529. package/ts/src/turn-deadline.ts +366 -0
  530. package/ts/src/turn-policy.ts +154 -0
  531. package/ts/src/unified.ts +444 -105
  532. package/ts/src/wish-pool.ts +79 -0
  533. package/dist/scripts/session-attach-wait.js +0 -41
@@ -0,0 +1,1166 @@
1
+ /**
2
+ * 事务化状态账本(ADR-15,docs/rfc/transactional-state-rfc.md):
3
+ * 「文件即权威」→「单文件 SQLite 账本即权威」。
4
+ *
5
+ * - events 表 = 唯一权威(append-only;语义幂等键物理化为 UNIQUE 索引)
6
+ * - 投影表(projection_docs/items)= 日志的确定性 fold,可随时 DROP 重建
7
+ * - 红线进事务:evidence/conditions 校验发生在写事务内,拒绝即回滚账本无痕
8
+ * - 一次写 = 单事务{INSERT event; UPDATE 投影},跨文件分叉在物理上不可能
9
+ * - 语义层零改造:mergeProfile/appendTrip/upsertCompanion/appendEvent 纯函数
10
+ * 原样复用为写路径守门与 fold 处理器(memory-design §1.6「账本化只换存储面」)
11
+ *
12
+ * 迁移纪律(D2 one-shot):旧 JSON/JSONL 文件在首次写时自动导入为 events
13
+ * (单事务 + 导入前快照到 gotry-state/pre-ledger-backup/);此后文件降级为
14
+ * 导出视图(state-cli export),永不再回流。读路径带文件回退(未迁移 root
15
+ * 仍可读旧文件),写路径必经账本——工具外无写入路径。
16
+ *
17
+ * 引擎:better-sqlite3(复用矩阵 open-source import 通道),WAL + NORMAL,
18
+ * 同步 API 与仓内 readFileSync 风格一致;busy_timeout 容忍跨进程
19
+ * (dsh 运行时 / async-collect / state-cli tick 并存)。
20
+ *
21
+ * 跨进程争用纪律(D-15 演练 D15-1/D15-2 的修复,issue #275 已于 2026-10-05
22
+ * 作为 deferred 关闭——触发出现时另开新 issue):
23
+ * - busy_timeout 必须在任何可争用语句之前装好(含 journal_mode = WAL);
24
+ * - 建表/迁移整段在 BEGIN IMMEDIATE 里做,版本探测与建表同处一个写锁;
25
+ * - 读改写事务一律 BEGIN IMMEDIATE(先读后写的 DEFERRED 事务升级锁时,
26
+ * SQLite 直接返回 SQLITE_BUSY_SNAPSHOT 且不调用 busy handler);
27
+ * - 争用重试有界,耗尽后抛 LedgerBusyError(典型化),不再漏裸 SQLITE_BUSY*。
28
+ * 单写者产品形态(ADR-16 一个账本一个 owner)本来就不会走到这些竞态,
29
+ * 以上只改争用路径的行为,单进程行为逐字节不变。
30
+ */
31
+
32
+ import Database from 'better-sqlite3'
33
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
34
+ import { createHash } from 'node:crypto'
35
+ import { dirname, isAbsolute, join } from 'node:path'
36
+ import { mergeProfile, type MergedProfile, type ProfilePatch } from './memory-capture.ts'
37
+ import { appendEvent as gateUtilityEvent, projectUtility, type MemoryUtilityEvent, type UtilityStatus } from './memory-utility.ts'
38
+ import { appendTrip as gateTrip, type TimelineEvent } from './travel-timeline.ts'
39
+ import { upsertCompanion, type CompanionProfile, type CompanionConstraints } from './companions.ts'
40
+ import type { WishPoolEntry } from './wish-pool.ts'
41
+
42
+ const SCHEMA_VERSION = '2'
43
+
44
+ export interface LedgerEventRow {
45
+ seq: number
46
+ tenant_id: string
47
+ ts: string
48
+ actor: string
49
+ kind: string
50
+ subject_id: string
51
+ payload: string
52
+ idem_key: string | null
53
+ run_id: string | null
54
+ }
55
+
56
+ /** 稳定 wish 主键:名称语义派生(重放/同名幂等更新不再依赖墙钟;RFC §1.1 id 不稳定缺口的修复) */
57
+ export function makeWishId(name: string): string {
58
+ return 'w-' + createHash('sha256').update(String(name).trim()).digest('hex').slice(0, 10)
59
+ }
60
+
61
+ function sha(s: string): string {
62
+ return createHash('sha256').update(s).digest('hex')
63
+ }
64
+
65
+ /** stateRoot 可相对可绝对(与 incident-log.resolveIncidentsPath 同款解析) */
66
+ function stateDirOf(stateRoot: string): string {
67
+ const root = isAbsolute(stateRoot) ? stateRoot : join(process.cwd(), stateRoot)
68
+ return join(root, 'gotry-state')
69
+ }
70
+
71
+ export function ledgerDbPath(stateRoot: string): string {
72
+ return join(stateDirOf(stateRoot), 'gotry-state.db')
73
+ }
74
+
75
+ export function ledgerExists(stateRoot: string): boolean {
76
+ return existsSync(ledgerDbPath(stateRoot))
77
+ }
78
+
79
+ const LEGACY_FILES = ['motivation-profile.json', 'wish-pool.json', 'memory-utility.jsonl', 'trips.jsonl', 'companions.json'] as const
80
+
81
+ function legacyFilesPresent(stateRoot: string): boolean {
82
+ const dir = stateDirOf(stateRoot)
83
+ return LEGACY_FILES.some(f => existsSync(join(dir, f)))
84
+ }
85
+
86
+ function isUniqueViolation(e: unknown): boolean {
87
+ return e instanceof Error && (e as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE'
88
+ }
89
+
90
+ // ---- 跨进程争用:有界重试 + 典型化出口(D15-1/D15-2) --------------------------
91
+
92
+ /** 写锁等待上限;跨进程(dsh 运行时 / async-collect / state-cli tick)共存的容忍面。 */
93
+ const BUSY_TIMEOUT_MS = 5_000
94
+ /** 争用重试次数上限(首次尝试之外);退避总额 < 400ms,宁可早报错也不无限等。 */
95
+ const CONTENTION_RETRIES = 5
96
+ /**
97
+ * 打开路径(WAL 切换 / 建表迁移)另按**墙钟**封顶:冷启动竞赛里失败往往是立即
98
+ * 抛出(busy handler 根本没被调用),次数上限会退化成「次数 × busy_timeout」那么长。
99
+ * 一次 open 最多争用 5s —— 正好等于 busy_timeout 的语义,然后给典型化错误。
100
+ */
101
+ const OPEN_CONTENTION_BUDGET_MS = 5_000
102
+ const OPEN_CONTENTION_RETRIES = 20
103
+
104
+ /**
105
+ * SQLite 把「此刻拿不到锁」编码成这几个码:
106
+ * SQLITE_BUSY / _TIMEOUT 写锁被别的连接占着(busy handler 已耗尽)
107
+ * SQLITE_BUSY_SNAPSHOT 读快照过期、无法升级为写(DEFERRED 读改写的死法)
108
+ * SQLITE_BUSY_RECOVERY WAL 正在被别的连接恢复
109
+ * SQLITE_PROTOCOL WAL index(-shm)争用导致的锁协议重试
110
+ * 只有这几个码允许重试;其余码(约束冲突/IO 错误/逻辑错)一律原样抛出——
111
+ * 真错误不得被重试掩盖成「忙」。
112
+ */
113
+ const CONTENTION_CODES: ReadonlySet<string> = new Set([
114
+ 'SQLITE_BUSY',
115
+ 'SQLITE_BUSY_TIMEOUT',
116
+ 'SQLITE_BUSY_SNAPSHOT',
117
+ 'SQLITE_BUSY_RECOVERY',
118
+ 'SQLITE_PROTOCOL',
119
+ ])
120
+
121
+ function sqliteCodeOf(e: unknown): string | undefined {
122
+ const code = (e as { code?: unknown } | null | undefined)?.code
123
+ return typeof code === 'string' ? code : undefined
124
+ }
125
+
126
+ function isContention(e: unknown): boolean {
127
+ const code = sqliteCodeOf(e)
128
+ return code !== undefined && CONTENTION_CODES.has(code)
129
+ }
130
+
131
+ /**
132
+ * 典型化的账本争用错误:有界重试耗尽后的唯一出口。
133
+ *
134
+ * 修复前跨进程争用会把 better-sqlite3 的裸 SQLITE_BUSY / SQLITE_BUSY_SNAPSHOT
135
+ * 直接扔给调用方(D15-1/D15-2),调用方只能嗅探字符串码。现在调用方可以
136
+ * `instanceof LedgerBusyError` 判定「只是忙、可稍后再来」,底层码仍保留在
137
+ * `sqliteCode`/`cause` 里供诊断。
138
+ */
139
+ export class LedgerBusyError extends Error {
140
+ /** 与 better-sqlite3 的 `code` 面同形,便于统一嗅探;永不与 SQLITE_* 撞名。 */
141
+ readonly code = 'GOTRY_LEDGER_BUSY'
142
+ readonly op: string
143
+ readonly attempts: number
144
+ readonly sqliteCode: string | undefined
145
+
146
+ constructor(op: string, attempts: number, cause: unknown) {
147
+ super(`ledger busy: ${op} 在 ${attempts} 次尝试后仍未取得写锁(末次 SQLite 码 ${sqliteCodeOf(cause) ?? 'unknown'})`, { cause })
148
+ this.name = 'LedgerBusyError'
149
+ this.op = op
150
+ this.attempts = attempts
151
+ this.sqliteCode = sqliteCodeOf(cause)
152
+ }
153
+ }
154
+
155
+ /** 同步退避:better-sqlite3 全同步,不能 await;Atomics.wait 是仓内既有惯用法。 */
156
+ function sleepSync(ms: number): void {
157
+ if (ms <= 0) return
158
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
159
+ }
160
+
161
+ /** 指数退避 + pid 抖动:N 个进程同时冷启动时不会同节奏再撞第二次。 */
162
+ function backoffMs(attempt: number): number {
163
+ return Math.min(5 * 2 ** attempt, 160) + (process.pid % 11)
164
+ }
165
+
166
+ /**
167
+ * 有界重试争用码,耗尽即抛 LedgerBusyError。
168
+ *
169
+ * 只对「整体提交或整体回滚」的单元使用:事务函数回滚后守门纯函数会重新读当前
170
+ * 状态再算一遍,所以重跑是幂等的(事件另有 idem_key UNIQUE 兜底)。
171
+ */
172
+ function withContentionRetry<T>(op: string, fn: () => T, opts: { retries?: number; budgetMs?: number } = {}): T {
173
+ const retries = opts.retries ?? CONTENTION_RETRIES
174
+ const deadline = opts.budgetMs === undefined ? undefined : Date.now() + opts.budgetMs
175
+ let lastError: unknown
176
+ let attempts = 0
177
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
178
+ attempts += 1
179
+ try {
180
+ return fn()
181
+ } catch (e) {
182
+ if (!isContention(e)) throw e
183
+ lastError = e
184
+ if (attempt >= retries) break
185
+ if (deadline !== undefined && Date.now() >= deadline) break
186
+ sleepSync(backoffMs(attempt))
187
+ }
188
+ }
189
+ throw new LedgerBusyError(op, attempts, lastError)
190
+ }
191
+
192
+ const SCHEMA = `
193
+ CREATE TABLE IF NOT EXISTS events (
194
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
195
+ tenant_id TEXT NOT NULL DEFAULT 'local',
196
+ ts TEXT NOT NULL,
197
+ actor TEXT NOT NULL,
198
+ kind TEXT NOT NULL,
199
+ subject_id TEXT NOT NULL DEFAULT '',
200
+ payload TEXT NOT NULL,
201
+ idem_key TEXT,
202
+ run_id TEXT
203
+ );
204
+ CREATE UNIQUE INDEX IF NOT EXISTS events_idem ON events(tenant_id, idem_key) WHERE idem_key IS NOT NULL;
205
+
206
+ CREATE TABLE IF NOT EXISTS projection_docs (
207
+ tenant_id TEXT NOT NULL DEFAULT 'local',
208
+ subject TEXT NOT NULL,
209
+ doc TEXT NOT NULL,
210
+ PRIMARY KEY (tenant_id, subject)
211
+ );
212
+ CREATE TABLE IF NOT EXISTS projection_items (
213
+ tenant_id TEXT NOT NULL DEFAULT 'local',
214
+ subject TEXT NOT NULL,
215
+ item_id TEXT NOT NULL,
216
+ doc TEXT NOT NULL,
217
+ ord INTEGER NOT NULL DEFAULT 0,
218
+ PRIMARY KEY (tenant_id, subject, item_id)
219
+ );
220
+
221
+ CREATE TABLE IF NOT EXISTS workflow_runs (
222
+ tenant_id TEXT NOT NULL DEFAULT 'local',
223
+ id TEXT NOT NULL,
224
+ goal TEXT NOT NULL,
225
+ status TEXT NOT NULL CHECK (status IN ('pending','settled','failed')),
226
+ ticket_json TEXT NOT NULL,
227
+ state_json TEXT NOT NULL,
228
+ deliverable TEXT,
229
+ created TEXT NOT NULL,
230
+ updated TEXT NOT NULL,
231
+ PRIMARY KEY (tenant_id, id)
232
+ );
233
+ CREATE TABLE IF NOT EXISTS workflow_steps (
234
+ tenant_id TEXT NOT NULL DEFAULT 'local',
235
+ run_id TEXT NOT NULL,
236
+ name TEXT NOT NULL,
237
+ status TEXT NOT NULL CHECK (status IN ('intent','done','failed')),
238
+ result TEXT,
239
+ intent_ts TEXT NOT NULL,
240
+ done_ts TEXT,
241
+ PRIMARY KEY (tenant_id, run_id, name)
242
+ );
243
+
244
+ CREATE TABLE IF NOT EXISTS pending_writes (
245
+ tenant_id TEXT NOT NULL DEFAULT 'local',
246
+ idem_key TEXT NOT NULL,
247
+ seam TEXT NOT NULL,
248
+ payload TEXT NOT NULL,
249
+ status TEXT NOT NULL CHECK (status IN ('pending','confirmed','compensated')),
250
+ receipt TEXT,
251
+ created TEXT NOT NULL,
252
+ updated TEXT NOT NULL,
253
+ PRIMARY KEY (tenant_id, idem_key)
254
+ );
255
+
256
+ CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL);
257
+ `
258
+
259
+ export interface WorkflowRunRow {
260
+ id: string
261
+ goal: string
262
+ status: string
263
+ ticket_json: string
264
+ state_json: string
265
+ deliverable: string | null
266
+ created: string
267
+ updated: string
268
+ }
269
+
270
+ export class StateLedger {
271
+ readonly db: Database.Database
272
+ readonly stateRoot: string
273
+ readonly tenant: string
274
+ readonly dbPath: string
275
+
276
+ constructor(db: Database.Database, stateRoot: string, tenant = 'local') {
277
+ this.db = db
278
+ this.stateRoot = stateRoot
279
+ this.tenant = tenant
280
+ this.dbPath = ledgerDbPath(stateRoot)
281
+ }
282
+
283
+ // ---- 基础事件面 ------------------------------------------------------------
284
+
285
+ /** 事件插入(idem 命中 → 返回 null,不抛;红线校验异常向上抛 = 事务回滚) */
286
+ insertEvent(ev: {
287
+ actor: string
288
+ kind: string
289
+ subjectId?: string
290
+ payload: unknown
291
+ idemKey?: string
292
+ runId?: string
293
+ ts?: string
294
+ }): number | null {
295
+ const stmt = this.db.prepare(
296
+ `INSERT INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id)
297
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
298
+ )
299
+ try {
300
+ const info = stmt.run(
301
+ this.tenant,
302
+ ev.ts ?? new Date().toISOString(),
303
+ ev.actor,
304
+ ev.kind,
305
+ ev.subjectId ?? '',
306
+ JSON.stringify(ev.payload),
307
+ ev.idemKey ?? null,
308
+ ev.runId ?? null,
309
+ )
310
+ return Number(info.lastInsertRowid)
311
+ } catch (e) {
312
+ if (isUniqueViolation(e)) return null
313
+ throw e
314
+ }
315
+ }
316
+
317
+ readEvents(kind?: string, limit = 100): LedgerEventRow[] {
318
+ const rows = kind
319
+ ? this.db.prepare(
320
+ `SELECT seq, tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id
321
+ FROM events WHERE tenant_id = ? AND kind = ? ORDER BY seq DESC LIMIT ?`,
322
+ ).all(this.tenant, kind, limit)
323
+ : this.db.prepare(
324
+ `SELECT seq, tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id
325
+ FROM events WHERE tenant_id = ? ORDER BY seq DESC LIMIT ?`,
326
+ ).all(this.tenant, limit)
327
+ return rows as LedgerEventRow[]
328
+ }
329
+
330
+ countEvents(): number {
331
+ return (this.db.prepare('SELECT COUNT(*) AS n FROM events WHERE tenant_id = ?').get(this.tenant) as { n: number }).n
332
+ }
333
+
334
+ // ---- 读:投影与日志类事件 ----------------------------------------------------
335
+
336
+ readMotivation(): (MergedProfile & { updated_at?: string }) | null {
337
+ const row = this.db.prepare(`SELECT doc FROM projection_docs WHERE subject = 'motivation' AND tenant_id = ?`).get(this.tenant) as { doc: string } | undefined
338
+ return row ? JSON.parse(row.doc) : null
339
+ }
340
+
341
+ readWishPool(): WishPoolEntry[] {
342
+ const rows = this.db.prepare(
343
+ `SELECT doc FROM projection_items WHERE subject = 'wish_pool' AND tenant_id = ? ORDER BY ord, rowid`,
344
+ ).all(this.tenant) as Array<{ doc: string }>
345
+ return rows.map(r => JSON.parse(r.doc) as WishPoolEntry)
346
+ }
347
+
348
+ readCompanions(): CompanionProfile[] {
349
+ const rows = this.db.prepare(
350
+ `SELECT doc FROM projection_items WHERE subject = 'companions' AND tenant_id = ? ORDER BY ord, rowid`,
351
+ ).all(this.tenant) as Array<{ doc: string }>
352
+ return rows.map(r => JSON.parse(r.doc) as CompanionProfile)
353
+ }
354
+
355
+ readUtilityEvents(): MemoryUtilityEvent[] {
356
+ const rows = this.db.prepare(
357
+ `SELECT payload FROM events WHERE kind = 'memory_utility.event' AND tenant_id = ? ORDER BY seq`,
358
+ ).all(this.tenant) as Array<{ payload: string }>
359
+ return rows.map(r => (JSON.parse(r.payload) as { event: MemoryUtilityEvent }).event)
360
+ }
361
+
362
+ readTrips(): TimelineEvent[] {
363
+ const rows = this.db.prepare(
364
+ `SELECT payload FROM events WHERE kind = 'trip.logged' AND tenant_id = ? ORDER BY seq`,
365
+ ).all(this.tenant) as Array<{ payload: string }>
366
+ return rows.map(r => (JSON.parse(r.payload) as { trip: TimelineEvent }).trip)
367
+ }
368
+
369
+ /** 效用投影(与文件版 projectUtility 同源同构) */
370
+ projectUtilityNow(): Record<string, { wish_id: string; status: UtilityStatus; recalled: number; applied: number; verified: number; last_event_ts?: string }> {
371
+ return projectUtility(this.readUtilityEvents())
372
+ }
373
+
374
+ // ---- 写:单事务{INSERT event; UPDATE 投影},守门纯函数原样复用 -----------------
375
+ // 红线进事务:evidence 缺失/conditions 空 → mergeProfile/校验拒绝 → 事务回滚,账本无痕。
376
+
377
+ /**
378
+ * 读改写事务的统一出口(D15-2 修复)。
379
+ *
380
+ * 为什么必须 BEGIN IMMEDIATE:DEFERRED 事务的第一条语句是 SELECT 时,SQLite 只
381
+ * 取读快照;等后面要写时若已有别的写者提交过,快照无法升级,SQLite **立即**返回
382
+ * SQLITE_BUSY_SNAPSHOT 并且**不调用 busy handler**(等待毫无意义,快照已过期)。
383
+ * 所以 busy_timeout 对这条路径完全无效——演练里 appendWish 需要调用方自己重试
384
+ * 6~9 次才做完 12 次尝试,就是这个原因。IMMEDIATE 开头就拿写锁,后来者改为在
385
+ * busy handler 里排队等待,SQLITE_BUSY_SNAPSHOT 在物理上不再可能出现。
386
+ *
387
+ * 重试是 IMMEDIATE 之外的第二道兜底(busy_timeout 真被耗尽时),整体回滚后重算,
388
+ * 幂等;耗尽 → LedgerBusyError。已在外层事务里(嵌套 = SAVEPOINT,模式被
389
+ * better-sqlite3 忽略)时不自行重试:重试归最外层事务所有,否则会在一个已经
390
+ * 注定回滚的事务里空转。
391
+ */
392
+ private runWrite<T>(op: string, fn: () => T): T {
393
+ const run = this.db.transaction(fn)
394
+ if (this.db.inTransaction) return run.immediate()
395
+ return withContentionRetry(`ledger.${op}`, () => run.immediate())
396
+ }
397
+
398
+ /**
399
+ * 全量事务审计(D15-2):判据只有一条——**事务的第一条语句是不是 SELECT**。
400
+ * 是 → 先读后写,会撞 SQLITE_BUSY_SNAPSHOT,必须走 runWrite(IMMEDIATE+有界重试);
401
+ * 不是(INSERT/UPDATE/DELETE 开头)→ 写锁在第一条语句就拿到,快照升级在物理上
402
+ * 不会发生,维持 DEFERRED 逐字节同义,不扩大改动面。
403
+ *
404
+ * runWrite(IMMEDIATE) appendMotivationPatch readMotivation 开头
405
+ * appendWish readWishPool 开头(演练实测失败点)
406
+ * appendTripEvent readTrips 开头
407
+ * appendCompanion readCompanions 开头
408
+ * confirmOutcome full 为空时以 readTrips 开头
409
+ * importLegacyInto SELECT kv.legacy_imported_v1 开头
410
+ * 维持 DEFERRED appendUtilityEvent 单条 INSERT
411
+ * rebuildProjections DELETE 投影开头(读在写锁之内)
412
+ * createWorkflowRun INSERT workflow_runs 开头
413
+ * finishWorkflowRun UPDATE workflow_runs 开头
414
+ * requestPendingWrite INSERT pending_writes 开头
415
+ * confirmPendingWrite UPDATE pending_writes 开头
416
+ * compensatePendingWrite UPDATE pending_writes 开头
417
+ * forgetSubject DELETE events 开头
418
+ * openDb 的建表/迁移事务另见 ensureSchema(D15-1)。
419
+ */
420
+
421
+ /** 动机画像增量补丁(守门 = mergeProfile:追加不删史/幂等/权重变更须伴新证据) */
422
+ appendMotivationPatch(patch: ProfilePatch, actor = 'tool:gotry_motivation_save'): {
423
+ saved: boolean
424
+ profile: MergedProfile & { updated_at?: string }
425
+ } {
426
+ // 事件边界 ISO:事件行的 ts 就是 homeCityPreference.updated_at 的源头,
427
+ // fold 路径会读同一字段回放——rebuildProjections 必须能复现该值。
428
+ const ts = new Date().toISOString()
429
+ // 读改写(readMotivation → mergeProfile → 写投影):第一条语句是 SELECT → IMMEDIATE。
430
+ return this.runWrite('appendMotivationPatch', (): { saved: boolean; profile: MergedProfile & { updated_at?: string } } => {
431
+ const current = this.readMotivation()
432
+ const merged = mergeProfile(current, patch, ts)
433
+ if (!merged) {
434
+ const unchanged = { ...(current ?? { weights: {}, evidence: [], hard: {} }), updated_at: current?.updated_at ?? ts } as MergedProfile & { updated_at?: string }
435
+ return { saved: false, profile: unchanged }
436
+ }
437
+ const doc = { ...merged, updated_at: ts }
438
+ this.insertEvent({ actor, kind: 'motivation.patch', subjectId: 'motivation', payload: { patch }, ts })
439
+ this.db.prepare(
440
+ `INSERT INTO projection_docs (tenant_id, subject, doc) VALUES (?, 'motivation', ?)
441
+ ON CONFLICT(tenant_id, subject) DO UPDATE SET doc = excluded.doc`,
442
+ ).run(this.tenant, JSON.stringify(doc))
443
+ return { saved: true, profile: doc }
444
+ })
445
+ }
446
+
447
+ /** 愿望入池/同名幂等更新(红线:conditions 必填非空) */
448
+ appendWish(entry: {
449
+ name?: unknown
450
+ reason?: unknown
451
+ conditions?: unknown
452
+ muted?: unknown
453
+ }, actor = 'tool:gotry_wish_pool_add'): { added: boolean; wish_id: string; total: number } {
454
+ const name = String(entry.name ?? '').trim()
455
+ const conditions = entry.conditions as Record<string, unknown> | undefined
456
+ if (!name || !conditions || typeof conditions !== 'object' || Object.keys(conditions).length === 0) {
457
+ throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)')
458
+ }
459
+ const ts = new Date().toISOString()
460
+ // 读改写(readWishPool → 同名判定 → 写事件+投影):第一条语句是 SELECT → IMMEDIATE。
461
+ // 这是 D15-2 演练里实际失败的那条路径。
462
+ return this.runWrite('appendWish', (): { added: boolean; wish_id: string; total: number } => {
463
+ const pool = this.readWishPool()
464
+ const existing = pool.find(w => String(w.name ?? '') === name)
465
+ if (existing) {
466
+ const id = typeof existing.wish_id === 'string' && existing.wish_id ? existing.wish_id : makeWishId(name)
467
+ const next = {
468
+ ...existing,
469
+ wish_id: id,
470
+ reason: entry.reason !== undefined ? entry.reason : existing.reason,
471
+ conditions,
472
+ ...(entry.muted !== undefined ? { muted: Boolean(entry.muted) } : {}),
473
+ } as WishPoolEntry
474
+ this.insertEvent({ actor, kind: 'wish.updated', subjectId: id, payload: { wish_id: id, patch: { reason: next.reason, conditions, ...(entry.muted !== undefined ? { muted: Boolean(entry.muted) } : {}) } }, ts })
475
+ this.upsertItem('wish_pool', id, next)
476
+ return { added: false, wish_id: id, total: pool.length }
477
+ }
478
+ const id = makeWishId(name)
479
+ const doc = {
480
+ wish_id: id,
481
+ name,
482
+ reason: entry.reason ?? '',
483
+ conditions,
484
+ ...(entry.muted !== undefined ? { muted: Boolean(entry.muted) } : {}),
485
+ added_at: ts,
486
+ } as WishPoolEntry
487
+ const seq = this.insertEvent({ actor, kind: 'wish.added', subjectId: id, payload: { wish: doc }, ts })
488
+ this.insertItem('wish_pool', id, doc, seq ?? 0)
489
+ return { added: true, wish_id: id, total: pool.length + 1 }
490
+ })
491
+ }
492
+
493
+ /** 效用事件(幂等键 = 语义 event_id;verified 无归因自动降级 applied——纯函数同款纪律) */
494
+ appendUtilityEvent(ev: Omit<MemoryUtilityEvent, 'schema' | 'event_id'>, actor = 'tool:gotry_wish_pool_list'): { appended: boolean; events: MemoryUtilityEvent[] } {
495
+ const full = gateUtilityEvent([], ev).events[0]
496
+ if (!full) return { appended: false, events: this.readUtilityEvents() }
497
+ // 非读改写:事务内只有一条 INSERT,写锁在第一条语句就拿到,不存在快照升级。
498
+ // 维持 DEFERRED(与修复前逐字节同义),争用只会落到 busy_timeout 等待上。
499
+ const run = this.db.transaction((): boolean => {
500
+ return this.insertEvent({ actor, kind: 'memory_utility.event', subjectId: full.wish_id, payload: { event: full }, idemKey: `mu:${full.event_id}` }) !== null
501
+ })
502
+ return { appended: run(), events: this.readUtilityEvents() }
503
+ }
504
+
505
+ /** 时间线行程(守门 = appendTrip:必填/绝对日期/重叠冲突即停;幂等键 = trip_id) */
506
+ appendTripEvent(ev: Omit<TimelineEvent, 'schema' | 'trip_id' | 'ts'>, actor = 'tool:gotry_trip_log'): { appended: boolean; tripId?: string; reason?: string; total: number } {
507
+ // 读改写(readTrips → 重叠冲突判定 → 写事件):第一条语句是 SELECT → IMMEDIATE。
508
+ // 重叠判定必须看见提交后的全量行程,否则两个进程可以各写一条互相重叠的行程。
509
+ return this.runWrite('appendTripEvent', (): { appended: boolean; tripId?: string; reason?: string; total: number } => {
510
+ const existing = this.readTrips()
511
+ const gate = gateTrip(existing, ev)
512
+ if (!gate.appended) return { appended: false, tripId: gate.tripId, reason: gate.reason, total: existing.length }
513
+ const full = gate.events[gate.events.length - 1] as TimelineEvent
514
+ this.insertEvent({ actor, kind: 'trip.logged', subjectId: full.trip_id, payload: { trip: full }, idemKey: `trip:${full.trip_id}` })
515
+ return { appended: true, tripId: full.trip_id, total: gate.events.length }
516
+ })
517
+ }
518
+
519
+ /** 同行人档案(守门 = upsertCompanion:负面清单拒收/追加不删史/幂等) */
520
+ appendCompanion(patch: { label: string; constraints: CompanionConstraints; evidence: string }, actor = 'tool:gotry_companion_save'): { appended: boolean; companionId: string; reason?: string; total: number } {
521
+ // 读改写(readCompanions → upsertCompanion → DELETE+INSERT 整组投影):
522
+ // 第一条语句是 SELECT → IMMEDIATE。replaceItems 整组重写,丢更新的代价最大。
523
+ return this.runWrite('appendCompanion', (): { appended: boolean; companionId: string; reason?: string; total: number } => {
524
+ const profiles = this.readCompanions()
525
+ const res = upsertCompanion(profiles, patch)
526
+ if (!res.appended) return { appended: false, companionId: res.companionId, reason: res.reason, total: profiles.length }
527
+ this.insertEvent({ actor, kind: 'companion.saved', subjectId: res.companionId, payload: { patch } })
528
+ this.replaceItems('companions', res.profiles.map(p => [p.companion_id, p]))
529
+ return { appended: true, companionId: res.companionId, total: res.profiles.length }
530
+ })
531
+ }
532
+
533
+ /**
534
+ * 成行确认(原跨两文件写 → 单事务):verified_outcome 效用事件 + 可选时间线行程,
535
+ * 同事务提交,崩溃后要么全有要么全无(RFC §1.1 partial-writes 缺口的修复)。
536
+ */
537
+ confirmOutcome(input: {
538
+ wishId: string
539
+ attribution: 'helpful' | 'harmful' | 'neutral'
540
+ detail?: string
541
+ trip?: Omit<TimelineEvent, 'schema' | 'trip_id' | 'ts'>
542
+ }, actor = 'tool:gotry_wish_pool_list'): {
543
+ recorded: boolean
544
+ trip?: { appended: boolean; tripId?: string; reason?: string }
545
+ } {
546
+ // 读改写:行程分支里 readTrips → gateTrip → 写事件;且 full 为空时整个事务
547
+ // 以 SELECT 开头。两个写同一事务提交的原子性正是本方法的存在理由 → IMMEDIATE。
548
+ return this.runWrite('confirmOutcome', (): { recorded: boolean; trip?: { appended: boolean; tripId?: string; reason?: string } } => {
549
+ const now = new Date().toISOString()
550
+ const full = gateUtilityEvent([], {
551
+ wish_id: input.wishId, kind: 'verified_outcome', ts: now,
552
+ ctx: 'gotry_wish_pool_list.confirm', detail: input.detail, attribution: input.attribution,
553
+ }).events[0]
554
+ const recorded = full
555
+ ? this.insertEvent({ actor, kind: 'memory_utility.event', subjectId: full.wish_id, payload: { event: full }, idemKey: `mu:${full.event_id}` }) !== null
556
+ : false
557
+ let trip: { appended: boolean; tripId?: string; reason?: string } | undefined
558
+ if (input.trip) {
559
+ const existing = this.readTrips()
560
+ const gate = gateTrip(existing, input.trip)
561
+ if (gate.appended) {
562
+ const fullTrip = gate.events[gate.events.length - 1] as TimelineEvent
563
+ this.insertEvent({ actor, kind: 'trip.logged', subjectId: fullTrip.trip_id, payload: { trip: fullTrip }, idemKey: `trip:${fullTrip.trip_id}` })
564
+ }
565
+ trip = { appended: gate.appended, tripId: gate.tripId, reason: gate.reason }
566
+ }
567
+ return { recorded, trip }
568
+ })
569
+ }
570
+
571
+ // ---- 投影维护 ---------------------------------------------------------------
572
+
573
+ private upsertItem(subject: string, itemId: string, doc: unknown): void {
574
+ const ord = (this.db.prepare(
575
+ `SELECT ord FROM projection_items WHERE subject = ? AND item_id = ? AND tenant_id = ?`,
576
+ ).get(subject, itemId, this.tenant) as { ord: number } | undefined)?.ord ?? this.nextOrd(subject)
577
+ this.db.prepare(
578
+ `INSERT INTO projection_items (tenant_id, subject, item_id, doc, ord) VALUES (?, ?, ?, ?, ?)
579
+ ON CONFLICT(tenant_id, subject, item_id) DO UPDATE SET doc = excluded.doc`,
580
+ ).run(this.tenant, subject, itemId, JSON.stringify(doc), ord)
581
+ }
582
+
583
+ private insertItem(subject: string, itemId: string, doc: unknown, ord: number): void {
584
+ this.db.prepare(
585
+ `INSERT INTO projection_items (tenant_id, subject, item_id, doc, ord) VALUES (?, ?, ?, ?, ?)
586
+ ON CONFLICT(tenant_id, subject, item_id) DO UPDATE SET doc = excluded.doc`,
587
+ ).run(this.tenant, subject, itemId, JSON.stringify(doc), ord)
588
+ }
589
+
590
+ private replaceItems(subject: string, items: Array<[string, unknown]>): void {
591
+ this.db.prepare('DELETE FROM projection_items WHERE subject = ? AND tenant_id = ?').run(subject, this.tenant)
592
+ const ins = this.db.prepare('INSERT INTO projection_items (tenant_id, subject, item_id, doc, ord) VALUES (?, ?, ?, ?, ?)')
593
+ items.forEach(([id, doc], i) => ins.run(this.tenant, subject, id, JSON.stringify(doc), i))
594
+ }
595
+
596
+ private nextOrd(subject: string): number {
597
+ return ((this.db.prepare(
598
+ 'SELECT COALESCE(MAX(ord), -1) AS m FROM projection_items WHERE subject = ? AND tenant_id = ?',
599
+ ).get(subject, this.tenant) as { m: number }).m ?? -1) + 1
600
+ }
601
+
602
+ /**
603
+ * fold 重建:DROP 全部投影 → 重放 events(可截到 toSeq)→ 重建投影。
604
+ * 与写路径共用同一套纯函数 → 直读投影与 fold 结果一致(账本/投影永不分叉的可验证性)。
605
+ */
606
+ rebuildProjections(toSeq?: number): { events: number; wishes: number; companions: number } {
607
+ // DELETE 开头 → 写锁即刻到手,维持 DEFERRED(审计表见 runWrite 上方);
608
+ // 且本方法常被 forgetSubject/importLegacyInto 嵌套调用,嵌套层不该自己重试。
609
+ const run = this.db.transaction((): { events: number; wishes: number; companions: number } => {
610
+ this.db.prepare('DELETE FROM projection_docs WHERE tenant_id = ?').run(this.tenant)
611
+ this.db.prepare('DELETE FROM projection_items WHERE tenant_id = ?').run(this.tenant)
612
+ const rows = (toSeq === undefined
613
+ ? this.db.prepare('SELECT seq, ts, kind, subject_id, payload FROM events WHERE tenant_id = ? ORDER BY seq')
614
+ : this.db.prepare('SELECT seq, ts, kind, subject_id, payload FROM events WHERE seq <= ? AND tenant_id = ? ORDER BY seq')
615
+ ).all(...(toSeq === undefined ? [this.tenant] : [toSeq, this.tenant])) as Array<{ seq: number; ts: string; kind: string; subject_id: string; payload: string }>
616
+ for (const row of rows) this.foldEvent(row)
617
+ const wishes = (this.db.prepare(`SELECT COUNT(*) AS n FROM projection_items WHERE subject = 'wish_pool' AND tenant_id = ?`).get(this.tenant) as { n: number }).n
618
+ const companions = (this.db.prepare(`SELECT COUNT(*) AS n FROM projection_items WHERE subject = 'companions' AND tenant_id = ?`).get(this.tenant) as { n: number }).n
619
+ return { events: rows.length, wishes, companions }
620
+ })
621
+ return run()
622
+ }
623
+
624
+ private foldEvent(row: { seq: number; ts: string; kind: string; subject_id: string; payload: string }): void {
625
+ const p = JSON.parse(row.payload) as Record<string, unknown>
626
+ switch (row.kind) {
627
+ case 'motivation.imported': {
628
+ const doc = p['profile'] as MergedProfile & { updated_at?: string }
629
+ this.db.prepare(
630
+ `INSERT INTO projection_docs (tenant_id, subject, doc) VALUES (?, 'motivation', ?)
631
+ ON CONFLICT(tenant_id, subject) DO UPDATE SET doc = excluded.doc`,
632
+ ).run(this.tenant, JSON.stringify(doc))
633
+ break
634
+ }
635
+ case 'motivation.patch': {
636
+ const current = this.readMotivation()
637
+ // 重建路径:时间戳 = 事件行 row.ts(账本事实);不在 fold 里再读时钟
638
+ const merged = mergeProfile(current, p['patch'] as ProfilePatch, row.ts)
639
+ if (merged) {
640
+ const doc = { ...merged, updated_at: row.ts }
641
+ this.db.prepare(
642
+ `INSERT INTO projection_docs (tenant_id, subject, doc) VALUES (?, 'motivation', ?)
643
+ ON CONFLICT(tenant_id, subject) DO UPDATE SET doc = excluded.doc`,
644
+ ).run(this.tenant, JSON.stringify(doc))
645
+ }
646
+ break
647
+ }
648
+ case 'wish.imported':
649
+ case 'wish.added': {
650
+ const wish = (p['wish'] ?? p['profile']) as WishPoolEntry
651
+ this.insertItem('wish_pool', String(wish.wish_id ?? row.subject_id), wish, row.seq)
652
+ break
653
+ }
654
+ case 'wish.updated': {
655
+ const id = String(p['wish_id'])
656
+ const patch = (p['patch'] ?? {}) as Record<string, unknown>
657
+ const rows = this.db.prepare(`SELECT doc FROM projection_items WHERE subject = 'wish_pool' AND item_id = ? AND tenant_id = ?`).get(id, this.tenant) as { doc: string } | undefined
658
+ if (rows) {
659
+ const doc = { ...JSON.parse(rows.doc), ...patch } as WishPoolEntry
660
+ this.upsertItem('wish_pool', id, doc)
661
+ }
662
+ break
663
+ }
664
+ case 'companion.imported': {
665
+ const profile = p['profile'] as CompanionProfile
666
+ this.insertItem('companions', profile.companion_id, profile, row.seq)
667
+ break
668
+ }
669
+ case 'companion.saved': {
670
+ const patch = p['patch'] as { label: string; constraints: CompanionConstraints; evidence: string }
671
+ const res = upsertCompanion(this.readCompanions(), patch)
672
+ this.replaceItems('companions', res.profiles.map(c => [c.companion_id, c]))
673
+ break
674
+ }
675
+ default:
676
+ break // 日志类事件(memory_utility/trip/async/write/forget)无投影
677
+ }
678
+ }
679
+
680
+ // ---- durable 工单(TS-3) -----------------------------------------------------
681
+
682
+ createWorkflowRun(input: { id: string; goal: string; ticket: unknown; state: unknown }, actor = 'system:async-request'): void {
683
+ const ts = new Date().toISOString()
684
+ // INSERT 开头 → 维持 DEFERRED(审计表见 runWrite 上方)。
685
+ const run = this.db.transaction(() => {
686
+ this.db.prepare(
687
+ `INSERT INTO workflow_runs (tenant_id, id, goal, status, ticket_json, state_json, deliverable, created, updated)
688
+ VALUES (?, ?, ?, 'pending', ?, ?, NULL, ?, ?)
689
+ ON CONFLICT(tenant_id, id) DO UPDATE SET updated = excluded.updated`,
690
+ ).run(this.tenant, input.id, input.goal, JSON.stringify(input.ticket), JSON.stringify(input.state), ts, ts)
691
+ this.insertEvent({ actor, kind: 'async.run_created', subjectId: input.id, payload: { ticket: input.ticket }, runId: input.id, ts })
692
+ })
693
+ run()
694
+ }
695
+
696
+ getWorkflowRun(id: string): WorkflowRunRow | undefined {
697
+ return this.db.prepare('SELECT * FROM workflow_runs WHERE id = ? AND tenant_id = ?').get(id, this.tenant) as WorkflowRunRow | undefined
698
+ }
699
+
700
+ pendingWorkflowRuns(): Array<{ id: string; goal: string }> {
701
+ return this.db.prepare(`SELECT id, goal FROM workflow_runs WHERE status = 'pending' AND tenant_id = ? ORDER BY created`).all(this.tenant) as Array<{ id: string; goal: string }>
702
+ }
703
+
704
+ getWorkflowStep(runId: string, name: string): { status: string; result: string | null } | undefined {
705
+ return this.db.prepare('SELECT status, result FROM workflow_steps WHERE run_id = ? AND name = ? AND tenant_id = ?').get(runId, name, this.tenant) as { status: string; result: string | null } | undefined
706
+ }
707
+
708
+ markStepIntent(runId: string, name: string): void {
709
+ this.db.prepare(
710
+ `INSERT INTO workflow_steps (tenant_id, run_id, name, status, intent_ts) VALUES (?, ?, ?, 'intent', ?)
711
+ ON CONFLICT(tenant_id, run_id, name) DO NOTHING`,
712
+ ).run(this.tenant, runId, name, new Date().toISOString())
713
+ }
714
+
715
+ markStepDone(runId: string, name: string, result: unknown): void {
716
+ this.db.prepare(
717
+ `UPDATE workflow_steps SET status = 'done', result = ?, done_ts = ? WHERE run_id = ? AND name = ? AND tenant_id = ?`,
718
+ ).run(JSON.stringify(result), new Date().toISOString(), runId, name, this.tenant)
719
+ }
720
+
721
+ private finishWorkflowRun(
722
+ id: string,
723
+ status: 'settled' | 'failed',
724
+ eventKind: 'async.settled' | 'async.failed',
725
+ deliverable: string,
726
+ terminalOutcome?: unknown,
727
+ actor = 'system:async-collect',
728
+ ): void {
729
+ // UPDATE ... WHERE status = 'pending' 开头(CAS 式状态推进)→ 维持 DEFERRED。
730
+ const run = this.db.transaction(() => {
731
+ const update = this.db.prepare(
732
+ `UPDATE workflow_runs SET status = ?, deliverable = ?, updated = ?
733
+ WHERE id = ? AND tenant_id = ? AND status = 'pending'`,
734
+ ).run(status, deliverable, new Date().toISOString(), id, this.tenant)
735
+ if (update.changes === 0) {
736
+ const existing = this.getWorkflowRun(id)
737
+ if (existing?.status === status) return
738
+ throw new Error(`workflow ${id} cannot transition ${existing?.status ?? 'missing'} -> ${status}`)
739
+ }
740
+ this.insertEvent({
741
+ actor,
742
+ kind: eventKind,
743
+ subjectId: id,
744
+ payload: { bytes: deliverable.length, terminal_outcome: terminalOutcome },
745
+ idemKey: `async-terminal:${id}`,
746
+ runId: id,
747
+ })
748
+ })
749
+ run()
750
+ }
751
+
752
+ settleWorkflowRun(id: string, deliverable: string, terminalOutcome?: unknown, actor = 'system:async-collect'): void {
753
+ this.finishWorkflowRun(id, 'settled', 'async.settled', deliverable, terminalOutcome, actor)
754
+ }
755
+
756
+ failWorkflowRun(id: string, deliverable: string, terminalOutcome: unknown, actor = 'system:async-collect'): void {
757
+ this.finishWorkflowRun(id, 'failed', 'async.failed', deliverable, terminalOutcome, actor)
758
+ }
759
+
760
+ getWorkflowTerminalOutcome(id: string): Record<string, unknown> | null {
761
+ const row = this.db.prepare(
762
+ `SELECT payload FROM events
763
+ WHERE tenant_id = ? AND run_id = ? AND kind IN ('async.settled', 'async.failed')
764
+ ORDER BY seq DESC LIMIT 1`,
765
+ ).get(this.tenant, id) as { payload: string } | undefined
766
+ if (!row) return null
767
+ try {
768
+ const payload = JSON.parse(row.payload) as { terminal_outcome?: Record<string, unknown> }
769
+ return payload.terminal_outcome ?? null
770
+ } catch {
771
+ return null
772
+ }
773
+ }
774
+
775
+ // ---- WriteGate saga 基座(TS-4, RFC S4 L2/L3 物理预备) -----------------------
776
+
777
+ /** 登记一笔待确认外部写(幂等键去重:同一确认不可能登记两次)。L2 = 只登记不执行。 */
778
+ requestPendingWrite(input: { idemKey: string; seam: string; payload: unknown }, actor = 'system:writegate'): { created: boolean; status: string } {
779
+ const ts = new Date().toISOString()
780
+ // INSERT ... ON CONFLICT DO NOTHING 开头 → 维持 DEFERRED。
781
+ const run = this.db.transaction((): { created: boolean; status: string } => {
782
+ const info = this.db.prepare(
783
+ `INSERT INTO pending_writes (tenant_id, idem_key, seam, payload, status, created, updated)
784
+ VALUES (?, ?, ?, ?, 'pending', ?, ?) ON CONFLICT(tenant_id, idem_key) DO NOTHING`,
785
+ ).run(this.tenant, input.idemKey, input.seam, JSON.stringify(input.payload ?? {}), ts, ts)
786
+ if (info.changes === 0) {
787
+ const row = this.db.prepare('SELECT status FROM pending_writes WHERE idem_key = ? AND tenant_id = ?').get(input.idemKey, this.tenant) as { status: string }
788
+ return { created: false, status: row.status }
789
+ }
790
+ this.insertEvent({ actor, kind: 'write.pending', subjectId: input.idemKey, payload: { seam: input.seam, payload: input.payload }, idemKey: `pw:${sha(input.idemKey)}:pending` })
791
+ return { created: true, status: 'pending' }
792
+ })
793
+ return run()
794
+ }
795
+
796
+ /** L3:具名 seam 确认——pending → confirmed,携 receipt(外部世界回执) */
797
+ confirmPendingWrite(idemKey: string, receipt: string, actor = 'system:writegate'): { ok: boolean; status: string } {
798
+ // UPDATE ... WHERE status = 'pending' 开头(CAS)→ 维持 DEFERRED。
799
+ const run = this.db.transaction((): { ok: boolean; status: string } => {
800
+ const info = this.db.prepare(
801
+ `UPDATE pending_writes SET status = 'confirmed', receipt = ?, updated = ? WHERE idem_key = ? AND status = 'pending' AND tenant_id = ?`,
802
+ ).run(receipt, new Date().toISOString(), idemKey, this.tenant)
803
+ if (info.changes === 0) {
804
+ const row = this.db.prepare('SELECT status FROM pending_writes WHERE idem_key = ? AND tenant_id = ?').get(idemKey, this.tenant) as { status: string } | undefined
805
+ return { ok: false, status: row?.status ?? 'missing' }
806
+ }
807
+ this.insertEvent({ actor, kind: 'write.confirmed', subjectId: idemKey, payload: { receipt } })
808
+ return { ok: true, status: 'confirmed' }
809
+ })
810
+ return run()
811
+ }
812
+
813
+ /** saga 补偿:pending/confirmed → compensated(SagaLLM 式补偿语义) */
814
+ compensatePendingWrite(idemKey: string, note: string, actor = 'system:writegate'): { ok: boolean; status: string } {
815
+ // UPDATE ... WHERE status != 'compensated' 开头(CAS)→ 维持 DEFERRED。
816
+ const run = this.db.transaction((): { ok: boolean; status: string } => {
817
+ const info = this.db.prepare(
818
+ `UPDATE pending_writes SET status = 'compensated', receipt = COALESCE(receipt, ?), updated = ? WHERE idem_key = ? AND status != 'compensated' AND tenant_id = ?`,
819
+ ).run(note, new Date().toISOString(), idemKey, this.tenant)
820
+ if (info.changes === 0) {
821
+ const row = this.db.prepare('SELECT status FROM pending_writes WHERE idem_key = ? AND tenant_id = ?').get(idemKey, this.tenant) as { status: string } | undefined
822
+ return { ok: false, status: row?.status ?? 'missing' }
823
+ }
824
+ this.insertEvent({ actor, kind: 'write.compensated', subjectId: idemKey, payload: { note } })
825
+ return { ok: true, status: 'compensated' }
826
+ })
827
+ return run()
828
+ }
829
+
830
+ listPendingWrites(): Array<{ idem_key: string; seam: string; status: string; receipt: string | null; created: string }> {
831
+ return this.db.prepare('SELECT idem_key, seam, status, receipt, created FROM pending_writes WHERE tenant_id = ? ORDER BY created').all(this.tenant) as Array<{ idem_key: string; seam: string; status: string; receipt: string | null; created: string }>
832
+ }
833
+
834
+ /** what-if 分叉:VACUUM INTO 副本,预演不触正本(LoopX「先只读投影后执行」的物理化) */
835
+ forkWhatIf(destPath: string): string {
836
+ this.db.prepare('VACUUM INTO ?').run(destPath)
837
+ return destPath
838
+ }
839
+
840
+ // ---- forget(D5:物理硬删,审计只留一行) ---------------------------------------
841
+
842
+ /** 物理删除某主体的全部事件并重建投影;删除本身留一行审计事件(红线 6「可删除」)。 */
843
+ forgetSubject(subjects: Array<{ kinds: string[]; subjectId: string }>, actor = 'system:state-cli'): { deleted: number } {
844
+ // DELETE events 开头 → 维持 DEFERRED(审计表见 runWrite 上方)。
845
+ const run = this.db.transaction((): { deleted: number } => {
846
+ let deleted = 0
847
+ for (const s of subjects) {
848
+ const placeholders = s.kinds.map(() => '?').join(',')
849
+ const info = this.db.prepare(
850
+ 'DELETE FROM events WHERE subject_id = ? AND tenant_id = ? AND kind IN (' + placeholders + ')',
851
+ ).run(s.subjectId, this.tenant, ...s.kinds)
852
+ deleted += info.changes
853
+ }
854
+ this.insertEvent({ actor, kind: 'forget.executed', payload: { subjects } })
855
+ this.rebuildProjections()
856
+ return { deleted }
857
+ })
858
+ return run()
859
+ }
860
+
861
+ close(): void {
862
+ this.db.close()
863
+ for (const [key, value] of openLedgers) if (value === this) openLedgers.delete(key)
864
+ }
865
+ }
866
+
867
+ // ---- 打开/迁移(one-shot D2;模块级缓存避免每次读开新句柄) ----------------------
868
+
869
+ const openLedgers = new Map<string, StateLedger>()
870
+
871
+ /**
872
+ * WAL 切换(D15-1 的第一个抛点)。
873
+ *
874
+ * 切成 WAL 要拿排他锁,而 `PRAGMA journal_mode` 并非在所有路径上都会走 busy
875
+ * handler,所以除了「busy_timeout 先装好」之外还要有界重试。每次失败先回看当前
876
+ * 模式:别的进程已经切成 wal 就直接收工——冷启动竞赛里这是最常见的结局。
877
+ *
878
+ * 不抛而仍未切成 wal(例如不支持 WAL 的文件系统)时保持修复前行为:照旧继续,
879
+ * 不编造「忙」。只有真的抛争用码且重试耗尽才给 LedgerBusyError。
880
+ */
881
+ function ensureWalMode(db: Database.Database): void {
882
+ const currentMode = (): string => String(db.pragma('journal_mode', { simple: true }) ?? '').toLowerCase()
883
+ if (currentMode() === 'wal') return
884
+ withContentionRetry('openDb journal_mode = WAL', () => {
885
+ if (currentMode() === 'wal') return // 竞赛里最常见的结局:别的进程已经切好了
886
+ db.pragma('journal_mode = WAL')
887
+ }, { retries: OPEN_CONTENTION_RETRIES, budgetMs: OPEN_CONTENTION_BUDGET_MS })
888
+ }
889
+
890
+ /**
891
+ * 建表/迁移(D15-1 的第二个抛点):整段进 BEGIN IMMEDIATE + 有界重试。
892
+ *
893
+ * 修复前版本探测在事务**外**,两个冷启动进程会各自读到 user_version = 0,再同时
894
+ * 进 DEFERRED 事务执行 `db.exec(SCHEMA)` —— 第一条 DDL 要升级成写,输的那个直接
895
+ * 抛裸 SQLITE_BUSY。现在版本探测与建表同处一个写锁下,后来者在 busy handler 里
896
+ * 排队,进去后重新读到 user_version = 2,于是只跑一遍幂等建表。
897
+ *
898
+ * 返回 isV1:v1→v2 升级后要重建投影,而那需要 StateLedger 实例,只能在事务外做。
899
+ */
900
+ function ensureSchema(db: Database.Database): boolean {
901
+ // 快路径:schema 已是 v2、kv 旗标在位、历史坏迁移已修 → 纯读探测,完全不取写锁。
902
+ // (修复前每次 open 都要写一次 kv schema_version,连只读调用方也在抢写锁,
903
+ // 冷启动竞赛因此更密;值本来就幂等,不写与写同义。)
904
+ if (schemaAlreadyCurrent(db)) return false
905
+ const kvSchemaVersion = 'INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v'
906
+ const migrate = db.transaction((): boolean => {
907
+ // 版本探测搬进事务:与建表同处一个写锁下,不再有两个进程各读到 0 的 TOCTOU。
908
+ const version = db.pragma('user_version', { simple: true }) as number
909
+ let isV1 = false
910
+ if (version < 2) {
911
+ // schema v1→v2 迁移:v1 无 tenant_id 列(user_version 未设置过,实际为 0)
912
+ // 检测是否真有 v1 数据(有 events 表但无 tenant_id 列)
913
+ const hasEventsTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get() !== undefined
914
+ const eventsHasTenant = hasEventsTable && (db.prepare('PRAGMA table_info(events)').all() as Array<{ name: string }>).some(c => c.name === 'tenant_id')
915
+ isV1 = hasEventsTable && !eventsHasTenant
916
+ if (isV1) {
917
+ // v1 → v2:events 加 tenant_id,回填 'local';投影/工单/pending_writes 强制重建(旧表无 tenant_id)
918
+ db.exec(`
919
+ ALTER TABLE events ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'local';
920
+ DROP INDEX IF EXISTS events_idem;
921
+ CREATE UNIQUE INDEX events_idem ON events(tenant_id, idem_key) WHERE idem_key IS NOT NULL;
922
+ DROP TABLE IF EXISTS projection_docs;
923
+ DROP TABLE IF EXISTS projection_items;
924
+ DROP TABLE IF EXISTS workflow_runs;
925
+ DROP TABLE IF EXISTS workflow_steps;
926
+ DROP TABLE IF EXISTS pending_writes;
927
+ `)
928
+ } else {
929
+ // v2 幂等:检查 workflow_runs 是否缺 tenant_id(修一次跑坏的迁移)
930
+ const wrHasTenant = (db.prepare('PRAGMA table_info(workflow_runs)').all() as Array<{ name: string }>).some(c => c.name === 'tenant_id')
931
+ if (!wrHasTenant && db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get()) {
932
+ db.exec(`
933
+ DROP TABLE IF EXISTS workflow_runs;
934
+ DROP TABLE IF EXISTS workflow_steps;
935
+ DROP TABLE IF EXISTS pending_writes;
936
+ DROP TABLE IF EXISTS projection_docs;
937
+ DROP TABLE IF EXISTS projection_items;
938
+ `)
939
+ }
940
+ }
941
+ db.exec(SCHEMA)
942
+ db.pragma('user_version = 2')
943
+ db.prepare(kvSchemaVersion).run('schema_version', SCHEMA_VERSION)
944
+ } else {
945
+ // 已是 v2:只做幂等建表(IF NOT EXISTS)。坏迁移修补只属于 version < 2 分支,
946
+ // 与修复前一致——user_version 已经是 2 时绝不去 DROP 任何可能有数据的表。
947
+ db.exec(SCHEMA)
948
+ db.prepare(kvSchemaVersion).run('schema_version', SCHEMA_VERSION)
949
+ }
950
+ return isV1
951
+ })
952
+ return withContentionRetry('openDb schema ensure/migrate', () => migrate.immediate(), {
953
+ retries: OPEN_CONTENTION_RETRIES,
954
+ budgetMs: OPEN_CONTENTION_BUDGET_MS,
955
+ })
956
+ }
957
+
958
+ /** 当前 SCHEMA 应当存在的全部对象;快路径据此判定「慢路径必然是空操作」。 */
959
+ const SCHEMA_OBJECTS = ['events', 'events_idem', 'projection_docs', 'projection_items', 'workflow_runs', 'workflow_steps', 'pending_writes', 'kv'] as const
960
+
961
+ /**
962
+ * 纯读探测:user_version 已是 2、SCHEMA 全部对象在位、kv 旗标已等于目标值
963
+ * → 慢路径(exec SCHEMA + kv upsert)可证明是空操作,于是整段写锁都省掉。
964
+ * 任何一项不满足(新库,或别的进程正在建而本连接还看不到)→ false,走慢路径。
965
+ */
966
+ function schemaAlreadyCurrent(db: Database.Database): boolean {
967
+ try {
968
+ if ((db.pragma('user_version', { simple: true }) as number) < 2) return false
969
+ const present = new Set(
970
+ (db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'index')").all() as Array<{ name: string }>).map(r => r.name),
971
+ )
972
+ if (!SCHEMA_OBJECTS.every(name => present.has(name))) return false
973
+ const row = db.prepare(`SELECT v FROM kv WHERE k = 'schema_version'`).get() as { v: string } | undefined
974
+ return row?.v === SCHEMA_VERSION
975
+ } catch {
976
+ return false
977
+ }
978
+ }
979
+
980
+ export function openDb(stateRoot: string, tenant = 'local'): StateLedger {
981
+ const path = ledgerDbPath(stateRoot)
982
+ const cacheKey = `${path}::${tenant}`
983
+ const cached = openLedgers.get(cacheKey)
984
+ if (cached) return cached
985
+ mkdirSync(dirname(path), { recursive: true })
986
+ const db = new Database(path)
987
+ let isV1 = false
988
+ try {
989
+ // D15-1 顺序红线:busy_timeout 必须在任何可争用语句之前装好。修复前它排在
990
+ // `journal_mode = WAL` 之后,于是 WAL 切换的排他锁被持有时,其余连接还没有
991
+ // busy handler —— 冷启动并发打开直接抛裸 SQLITE_BUSY。顺序不可再调换。
992
+ db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`)
993
+ ensureWalMode(db)
994
+ db.pragma('synchronous = NORMAL')
995
+ isV1 = ensureSchema(db)
996
+ } catch (e) {
997
+ // 打开失败不留半开句柄(重试/上抛都不该泄漏 better-sqlite3 的文件描述符)。
998
+ try { db.close() } catch { /* 句柄已废,关不掉不影响裁决 */ }
999
+ throw e
1000
+ }
1001
+ // v1 升级后:旧单用户事件只可安全归入 local;即使调用者打开非 local 租户,也只重建 local 投影。
1002
+ if (isV1) {
1003
+ const ledger = new StateLedger(db, stateRoot, 'local')
1004
+ ledger.rebuildProjections()
1005
+ }
1006
+ const ledger = new StateLedger(db, stateRoot, tenant)
1007
+ openLedgers.set(cacheKey, ledger)
1008
+ return ledger
1009
+ }
1010
+
1011
+ /** 只读场景:账本不存在返回 null(调用方走文件回退) */
1012
+ export function openLedgerIfExists(stateRoot: string, tenant = 'local'): StateLedger | null {
1013
+ if (!ledgerExists(stateRoot)) return null
1014
+ return openDb(stateRoot, tenant)
1015
+ }
1016
+
1017
+ export interface MigrationReport {
1018
+ migrated: boolean
1019
+ imported: { motivation: boolean; wishes: number; utilityEvents: number; trips: number; companions: number }
1020
+ backupDir: string | null
1021
+ }
1022
+
1023
+ /**
1024
+ * 确保账本可用(写路径必经):
1025
+ * - DB 已存在 → 直接用
1026
+ * - 无 DB 无旧文件 → 建空账本(fresh root,smoke/测试形态)
1027
+ * - 无 DB 有旧文件 → 快照 → 建账本 → 旧数据单事务导入为 events(one-shot;
1028
+ * 导入幂等键 + kv 旗标双保险,并发 ensure 不重复导入)
1029
+ */
1030
+ export function ensureLedger(stateRoot: string, tenant = 'local'): StateLedger {
1031
+ if (ledgerExists(stateRoot)) return openDb(stateRoot, tenant)
1032
+ const dir = stateDirOf(stateRoot)
1033
+ const hasLegacy = legacyFilesPresent(stateRoot)
1034
+ let backupDir: string | null = null
1035
+ if (hasLegacy) {
1036
+ backupDir = join(dir, 'pre-ledger-backup')
1037
+ mkdirSync(backupDir, { recursive: true })
1038
+ for (const f of LEGACY_FILES) {
1039
+ if (existsSync(join(dir, f))) copyFileSync(join(dir, f), join(backupDir, f))
1040
+ }
1041
+ }
1042
+ if (hasLegacy) {
1043
+ // 旧 JSON/JSONL 是单用户本地状态,没有可审计租户归属;迁移边界=只归入 local,绝不按调用 tenant 猜测或搬移。
1044
+ const localLedger = openDb(stateRoot, 'local')
1045
+ importLegacyInto(localLedger, dir)
1046
+ return tenant === 'local' ? localLedger : openDb(stateRoot, tenant)
1047
+ }
1048
+ return openDb(stateRoot, tenant)
1049
+ }
1050
+
1051
+ function importLegacyInto(ledger: StateLedger, dir: string): MigrationReport['imported'] {
1052
+ const imported = { motivation: false, wishes: 0, utilityEvents: 0, trips: 0, companions: 0 }
1053
+ // 读改写:以 SELECT kv.legacy_imported_v1 开头(旗标判定)→ IMMEDIATE + 有界重试。
1054
+ // 「并发 ensure 不重复导入」靠的就是这个旗标,而 DEFERRED 事务下两个进程会同时
1055
+ // 读到无旗标、再在写时一个拿锁一个抛 SQLITE_BUSY_SNAPSHOT —— 正是 D15-2 的形状。
1056
+ const run = ledger.db.transaction(() => {
1057
+ // 重试要幂等:计数器属于「本次事务的成果」,每次尝试重新归零,回滚过的那次
1058
+ // 不得残留在报告里。
1059
+ Object.assign(imported, { motivation: false, wishes: 0, utilityEvents: 0, trips: 0, companions: 0 })
1060
+ const flag = ledger.db.prepare(`SELECT v FROM kv WHERE k = 'legacy_imported_v1'`).get() as { v: string } | undefined
1061
+ if (flag) return
1062
+ const readJsonSafe = (p: string): unknown | null => {
1063
+ try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { return null }
1064
+ }
1065
+ // 动机画像:整档快照事件(fold 直接落投影)
1066
+ const profile = readJsonSafe(join(dir, 'motivation-profile.json')) as Record<string, unknown> | null
1067
+ if (profile) {
1068
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'motivation.imported', subjectId: 'motivation', payload: { profile }, idemKey: 'import:motivation' }) !== null) imported.motivation = true
1069
+ }
1070
+ // 愿望池:逐条导入(保留原 wish_id)
1071
+ const pool = readJsonSafe(join(dir, 'wish-pool.json')) as Array<Record<string, unknown>> | null
1072
+ if (Array.isArray(pool)) {
1073
+ for (const w of pool) {
1074
+ const id = String(w['wish_id'] ?? makeWishId(String(w['name'] ?? '')))
1075
+ const wish = { ...w, wish_id: id }
1076
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'wish.imported', subjectId: id, payload: { wish }, idemKey: `import:wish:${id}` }) !== null) imported.wishes++
1077
+ }
1078
+ }
1079
+ // 效用流:逐行导入(语义 event_id 即幂等键)
1080
+ try {
1081
+ const lines = readFileSync(join(dir, 'memory-utility.jsonl'), 'utf-8').split('\n').filter(Boolean)
1082
+ for (const line of lines) {
1083
+ try {
1084
+ const ev = JSON.parse(line) as MemoryUtilityEvent
1085
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'memory_utility.event', subjectId: ev.wish_id, payload: { event: ev }, idemKey: `mu:${ev.event_id}` }) !== null) imported.utilityEvents++
1086
+ } catch { /* 脏行跳过:导入永不因单行失败而中断 */ }
1087
+ }
1088
+ } catch { /* 文件不存在 */ }
1089
+ // 时间线:逐行导入(trip_id 幂等)
1090
+ try {
1091
+ const lines = readFileSync(join(dir, 'trips.jsonl'), 'utf-8').split('\n').filter(Boolean)
1092
+ for (const line of lines) {
1093
+ try {
1094
+ const trip = JSON.parse(line) as TimelineEvent
1095
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'trip.logged', subjectId: trip.trip_id, payload: { trip }, idemKey: `trip:${trip.trip_id}` }) !== null) imported.trips++
1096
+ } catch { /* 脏行跳过 */ }
1097
+ }
1098
+ } catch { /* 文件不存在 */ }
1099
+ // 同行人:逐条导入(保留 companion_id)
1100
+ const companions = readJsonSafe(join(dir, 'companions.json')) as Array<Record<string, unknown>> | null
1101
+ if (Array.isArray(companions)) {
1102
+ for (const c of companions) {
1103
+ const id = String(c['companion_id'] ?? '')
1104
+ if (!id) continue
1105
+ if (ledger.insertEvent({ actor: 'system:migrate', kind: 'companion.imported', subjectId: id, payload: { profile: c }, idemKey: `import:companion:${id}` }) !== null) imported.companions++
1106
+ }
1107
+ }
1108
+ ledger.rebuildProjections()
1109
+ ledger.db.prepare(`INSERT INTO kv (k, v) VALUES ('legacy_imported_v1', 'done') ON CONFLICT(k) DO UPDATE SET v = excluded.v`).run()
1110
+ })
1111
+ withContentionRetry('ensureLedger legacy import', () => run.immediate())
1112
+ return imported
1113
+ }
1114
+
1115
+ // ---- 读路径统一出口:账本优先,未迁移 root 回退旧文件(只读,绝不反向写) ---------
1116
+
1117
+ function readLegacyJson<T>(stateRoot: string, name: string, fallback: T): T {
1118
+ try {
1119
+ return JSON.parse(readFileSync(join(stateDirOf(stateRoot), name), 'utf-8')) as T
1120
+ } catch {
1121
+ return fallback
1122
+ }
1123
+ }
1124
+
1125
+ function readLegacyJsonl<T>(stateRoot: string, name: string): T[] {
1126
+ try {
1127
+ return readFileSync(join(stateDirOf(stateRoot), name), 'utf-8').split('\n').filter(Boolean).map(l => JSON.parse(l) as T)
1128
+ } catch {
1129
+ return []
1130
+ }
1131
+ }
1132
+
1133
+ export function readMotivationWithFallback(stateRoot: string, tenant = 'local'): (MergedProfile & { updated_at?: string }) | null {
1134
+ const ledger = openLedgerIfExists(stateRoot, tenant)
1135
+ if (ledger) return ledger.readMotivation()
1136
+ if (tenant !== 'local') return null
1137
+ return readLegacyJson<(MergedProfile & { updated_at?: string }) | null>(stateRoot, 'motivation-profile.json', null)
1138
+ }
1139
+
1140
+ export function readWishPoolWithFallback(stateRoot: string, tenant = 'local'): WishPoolEntry[] {
1141
+ const ledger = openLedgerIfExists(stateRoot, tenant)
1142
+ if (ledger) return ledger.readWishPool()
1143
+ if (tenant !== 'local') return []
1144
+ return readLegacyJson<WishPoolEntry[]>(stateRoot, 'wish-pool.json', [])
1145
+ }
1146
+
1147
+ export function readUtilityEventsWithFallback(stateRoot: string, tenant = 'local'): MemoryUtilityEvent[] {
1148
+ const ledger = openLedgerIfExists(stateRoot, tenant)
1149
+ if (ledger) return ledger.readUtilityEvents()
1150
+ if (tenant !== 'local') return []
1151
+ return readLegacyJsonl<MemoryUtilityEvent>(stateRoot, 'memory-utility.jsonl')
1152
+ }
1153
+
1154
+ export function readTripsWithFallback(stateRoot: string, tenant = 'local'): TimelineEvent[] {
1155
+ const ledger = openLedgerIfExists(stateRoot, tenant)
1156
+ if (ledger) return ledger.readTrips()
1157
+ if (tenant !== 'local') return []
1158
+ return readLegacyJsonl<TimelineEvent>(stateRoot, 'trips.jsonl')
1159
+ }
1160
+
1161
+ export function readCompanionsWithFallback(stateRoot: string, tenant = 'local'): CompanionProfile[] {
1162
+ const ledger = openLedgerIfExists(stateRoot, tenant)
1163
+ if (ledger) return ledger.readCompanions()
1164
+ if (tenant !== 'local') return []
1165
+ return readLegacyJson<CompanionProfile[]>(stateRoot, 'companions.json', [])
1166
+ }