@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.
- package/README.md +172 -219
- package/README.zh-CN.md +222 -0
- package/bin/gotry-backend.js +53 -0
- package/bin/gotry-booking-copilot.js +53 -0
- package/bin/gotry-bootstrap.js +1788 -0
- package/bin/gotry-inner.js +733 -83
- package/bin/gotry-process-liveness.d.ts +27 -0
- package/bin/gotry-process-liveness.js +97 -0
- package/bin/gotry-runtime-resolution.d.ts +27 -0
- package/bin/gotry-runtime-resolution.js +49 -0
- package/bin/gotry.js +1 -1
- package/client/client.js +203 -0
- package/cordis.gotry-patch.yml +80 -56
- package/data/airline-airports.json +39 -0
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach-deep.js +45 -122
- package/dist/capabilities/agent-reach.js +101 -146
- package/dist/capabilities/anything.js +161 -94
- package/dist/capabilities/artifacts.js +370 -0
- package/dist/capabilities/channel-health.js +131 -0
- package/dist/capabilities/channel-registry.js +256 -0
- package/dist/capabilities/doctor.js +553 -0
- package/dist/capabilities/effect.js +394 -0
- package/dist/capabilities/external-event.js +149 -0
- package/dist/capabilities/fact-log.js +55 -0
- package/dist/capabilities/flyai-config.js +337 -0
- package/dist/capabilities/flyai.js +947 -110
- package/dist/capabilities/fx-contract.js +293 -0
- package/dist/capabilities/geo-atlas.js +246 -0
- package/dist/capabilities/ground-transfer.js +676 -0
- package/dist/capabilities/hbcli.js +260 -123
- package/dist/capabilities/hotelbyte-transaction.js +194 -0
- package/dist/capabilities/incident-log.js +131 -61
- package/dist/capabilities/itinerary-artifact.js +190 -0
- package/dist/capabilities/itinerary-deck-artifact.js +194 -0
- package/dist/capabilities/itinerary-deck-export.js +339 -0
- package/dist/capabilities/lavish-local.js +924 -0
- package/dist/capabilities/model-override.js +38 -0
- package/dist/capabilities/opensky.js +54 -43
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/resilience.js +191 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/action-cache.js +50 -44
- package/dist/capabilities/session/adapters/ctrip-flight.js +120 -62
- package/dist/capabilities/session/adapters/ctrip-hotel.js +222 -0
- package/dist/capabilities/session/adapters/dida-portal.js +274 -0
- package/dist/capabilities/session/adapters/meituan-local.js +35 -54
- package/dist/capabilities/session/adapters/rail-12306.js +342 -0
- package/dist/capabilities/session/benchmark.js +189 -0
- package/dist/capabilities/session/bridge-ledger.js +224 -0
- package/dist/capabilities/session/extension-bridge.js +636 -0
- package/dist/capabilities/session/extension-channel.js +109 -0
- package/dist/capabilities/session/extension-distribution.js +230 -0
- package/dist/capabilities/session/extract.js +31 -21
- package/dist/capabilities/session/golden-score.js +114 -0
- package/dist/capabilities/session/health-watch.js +215 -0
- package/dist/capabilities/session/read-guard.js +75 -24
- package/dist/capabilities/session/static-flight-golden.js +150 -0
- package/dist/capabilities/session/transport.js +97 -83
- package/dist/capabilities/session/wizard.js +117 -0
- package/dist/capabilities/session-consent.js +178 -0
- package/dist/capabilities/session-login.js +170 -0
- package/dist/capabilities/session-search.js +635 -44
- package/dist/capabilities/spawn-bounded.js +203 -0
- package/dist/capabilities/sponsor-plugin.js +341 -0
- package/dist/capabilities/visa-policy.js +140 -0
- package/dist/capabilities/weather.js +243 -138
- package/dist/data/session-golden-20.json +25 -0
- package/dist/data/sf-golden-manifest.json +103 -0
- package/dist/data/sf-static-routes.json +91 -0
- package/dist/scripts/action-cache-tests.js +24 -59
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +242 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +185 -0
- package/dist/scripts/agent-reach-deep-tests.js +30 -40
- package/dist/scripts/agent-reach-error-tests.js +354 -0
- package/dist/scripts/agent-reach-tests.js +26 -18
- package/dist/scripts/agent-reach-wrapper-tests.js +50 -50
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +250 -60
- package/dist/scripts/artifact-client-contract-tests.js +220 -0
- package/dist/scripts/artifacts-capability-tests.js +727 -0
- package/dist/scripts/async-collect.js +50 -11
- package/dist/scripts/benchmark-environment-bridge-e2e.js +741 -0
- package/dist/scripts/benchmark-environment-bridge-tests.js +1452 -0
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +126 -0
- package/dist/scripts/booking-copilot-availability-policy-tests.js +265 -0
- package/dist/scripts/booking-copilot-bin-proof-tests.js +66 -0
- package/dist/scripts/booking-copilot-cleanup-proof-helper.js +76 -0
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +144 -0
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +424 -0
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +2839 -0
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +309 -0
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +238 -0
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +126 -0
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +156 -0
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +70 -0
- package/dist/scripts/booking-copilot-intent-proof-tests.js +422 -0
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +184 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +95 -0
- package/dist/scripts/booking-copilot-proof-runtime.js +42 -0
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +210 -0
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +2349 -0
- package/dist/scripts/booking-copilot-server-proof-tests.js +784 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +94 -0
- package/dist/scripts/booking-executor-tests.js +80 -0
- package/dist/scripts/booking-recovery-chain-tests.js +182 -0
- package/dist/scripts/booking-saga-tests.js +125 -0
- package/dist/scripts/booking-surface-contract-proof-tests.js +212 -0
- package/dist/scripts/bootstrap-tests.js +1549 -0
- package/dist/scripts/bridge-events-tests.js +239 -0
- package/dist/scripts/bridge-ledger-tests.js +428 -0
- package/dist/scripts/build-changelog.js +238 -0
- package/dist/scripts/build-metrics-report.js +331 -0
- package/dist/scripts/changelog-tests.js +118 -0
- package/dist/scripts/channel-probe-tests.js +103 -0
- package/dist/scripts/channel-probe.js +169 -0
- package/dist/scripts/channel-registry-tests.js +208 -0
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/companion-tests.js +25 -70
- package/dist/scripts/dida-cookie-probe.js +87 -0
- package/dist/scripts/dida-runner-stop-tests.js +311 -0
- package/dist/scripts/dida-sw-probe.js +114 -0
- package/dist/scripts/diff-test.js +17 -12
- package/dist/scripts/doctor-tests.js +600 -0
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-artifact-e2e.js +279 -0
- package/dist/scripts/dsh-artifact-web-e2e.js +675 -0
- package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/dsh-runtime-closure-tests.js +182 -0
- package/dist/scripts/dsh-runtime-closure.js +162 -0
- package/dist/scripts/dsh-session-v3-migration-proof.js +109 -0
- package/dist/scripts/dsh-subprocess-local-proof.js +150 -0
- package/dist/scripts/dsh-target-closure-proof.js +178 -0
- package/dist/scripts/effect-tests.js +539 -0
- package/dist/scripts/engine-run.js +1 -3
- package/dist/scripts/engine-tests.js +16 -10
- package/dist/scripts/evaluation-cadence-tests.js +174 -0
- package/dist/scripts/evaluation-contract-tests.js +268 -0
- package/dist/scripts/extension-distribution-cli.js +39 -0
- package/dist/scripts/extension-distribution-tests.js +443 -0
- package/dist/scripts/extension-tests.js +988 -0
- package/dist/scripts/external-event-tests.js +350 -0
- package/dist/scripts/fact-gate-tests.js +869 -0
- package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
- package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
- package/dist/scripts/flight-malformed-tests.js +159 -0
- package/dist/scripts/flyai-setup-tests.js +228 -0
- package/dist/scripts/flyai-setup-tool-tests.js +54 -0
- package/dist/scripts/flyai-tests.js +692 -0
- package/dist/scripts/flyai-tool-registration-contract-tests.js +91 -0
- package/dist/scripts/fx-contract-tests.js +201 -0
- package/dist/scripts/g5-guard-tests.js +150 -0
- package/dist/scripts/g5-guard.js +193 -0
- package/dist/scripts/gen-lunar-anchors.js +106 -0
- package/dist/scripts/geo-atlas-tests.js +283 -0
- package/dist/scripts/gotry-backend-kernel-tests.js +206 -0
- package/dist/scripts/gotry-try-demo-tests.js +79 -0
- package/dist/scripts/gotry-try-demo.js +49 -0
- package/dist/scripts/ground-transfer-tests.js +956 -0
- package/dist/scripts/hbcli-e2e-tests.js +159 -0
- package/dist/scripts/hbcli-live-optin-tests.js +96 -0
- package/dist/scripts/hbcli-release-contract-tests.js +122 -0
- package/dist/scripts/hbcli-tests.js +121 -69
- package/dist/scripts/health-watch-cli.js +73 -0
- package/dist/scripts/hotel-date-gate-tests.js +496 -0
- package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
- package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
- package/dist/scripts/i18n-tests.js +41 -0
- package/dist/scripts/incident-tests.js +281 -87
- package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
- package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
- package/dist/scripts/issue-271-liveness-tests.js +569 -0
- package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
- package/dist/scripts/issue-289-model-retry-tests.js +441 -0
- package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
- package/dist/scripts/issue-338-home-city-e2e.js +577 -0
- package/dist/scripts/issue343-real-entry-e2e.js +160 -0
- package/dist/scripts/issue436-persisted-routing-e2e.js +247 -0
- package/dist/scripts/itinerary-artifact-tests.js +368 -0
- package/dist/scripts/itinerary-deck-artifact-tests.js +416 -0
- package/dist/scripts/itinerary-deck-export-tests.js +552 -0
- package/dist/scripts/itinerary-deck-tests.js +435 -0
- package/dist/scripts/itinerary-html-tests.js +426 -0
- package/dist/scripts/journey-tests.js +22 -27
- package/dist/scripts/kernel-manifest-evidence.js +109 -0
- package/dist/scripts/kernel-manifest-gate.js +126 -0
- package/dist/scripts/kernel-manifest-tests.js +212 -0
- package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
- package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
- package/dist/scripts/kernel-manifest.js +369 -0
- package/dist/scripts/lavish-local-tests.js +1226 -0
- package/dist/scripts/lavish-product-tools-tests.js +537 -0
- package/dist/scripts/ledger-repair-apply-tests.js +316 -0
- package/dist/scripts/ledger-repair-plan-tests.js +375 -0
- package/dist/scripts/ledger-tests.js +382 -246
- package/dist/scripts/ledger-workflow-crash.js +10 -20
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/managed-dsh-cleanup-diagnostic-proof.js +184 -0
- package/dist/scripts/managed-dsh-close-failure-proof.js +172 -0
- package/dist/scripts/managed-dsh-run-port-proof.js +153 -0
- package/dist/scripts/managed-dsh-terminal-proof.js +252 -0
- package/dist/scripts/map-tools-vendor-package-proof.js +365 -0
- package/dist/scripts/memory-capture-tests.js +242 -61
- package/dist/scripts/memory-decay-tests.js +18 -52
- package/dist/scripts/memory-lifecycle-tests.js +657 -0
- package/dist/scripts/memory-lifecycle.js +196 -0
- package/dist/scripts/memory-metrics.js +24 -12
- package/dist/scripts/memory-value-report.js +631 -0
- package/dist/scripts/memory-value-tests.js +328 -0
- package/dist/scripts/metrics-report-tests.js +151 -0
- package/dist/scripts/model-override-e2e.js +137 -0
- package/dist/scripts/nightly-evidence-tests.js +203 -0
- package/dist/scripts/nightly-evidence.js +303 -0
- package/dist/scripts/nudge-digest.js +39 -28
- package/dist/scripts/observability-tests.js +381 -0
- package/dist/scripts/onboarding-tests.js +161 -0
- package/dist/scripts/opensky-check.js +18 -13
- package/dist/scripts/opensky-tests.js +28 -28
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +603 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/persona-surface-guard-tests.js +49 -0
- package/dist/scripts/pnpm-dsh-closure-proof.js +17 -0
- package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
- package/dist/scripts/price-drift-tests.js +340 -0
- package/dist/scripts/price-drift-watch.js +447 -0
- package/dist/scripts/probe-poi-tests.js +53 -45
- package/dist/scripts/product-metrics.js +483 -0
- package/dist/scripts/publish-preverify.js +110 -0
- package/dist/scripts/realtime-pricing-tests.js +76 -0
- package/dist/scripts/recall-tests.js +513 -0
- package/dist/scripts/replay-async.js +22 -18
- package/dist/scripts/replay-real.js +18 -18
- package/dist/scripts/replay.js +54 -82
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-attach-diagnose.js +20 -20
- package/dist/scripts/session-attach-poc.js +34 -37
- package/dist/scripts/session-benchmark.js +180 -0
- package/dist/scripts/session-dida-live-e2e.js +199 -0
- package/dist/scripts/session-extract-tests.js +14 -35
- package/dist/scripts/session-link-tests.js +478 -0
- package/dist/scripts/session-login.js +27 -46
- package/dist/scripts/session-tests.js +796 -96
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +374 -0
- package/dist/scripts/sf-live-benchmark.js +367 -0
- package/dist/scripts/sf-live-challenge-stop-tests.js +453 -0
- package/dist/scripts/sf-live-cli-tests.js +92 -0
- package/dist/scripts/sf-live-options.js +46 -0
- package/dist/scripts/sf-manifest-tests.js +30 -0
- package/dist/scripts/sf-manifest.js +33 -0
- package/dist/scripts/sf-soft-score-tests.js +67 -0
- package/dist/scripts/sf-summary-tests.js +324 -0
- package/dist/scripts/sf-summary.js +525 -0
- package/dist/scripts/share-tests.js +432 -0
- package/dist/scripts/skeleton-check.js +16 -14
- package/dist/scripts/skeleton-integration-test.js +28 -11
- package/dist/scripts/skills-contract-tests.js +51 -46
- package/dist/scripts/smoke-session-gate-tests.js +21 -0
- package/dist/scripts/smoke.js +690 -246
- package/dist/scripts/spawn-bounded-abort-tests.js +244 -0
- package/dist/scripts/sponsor-reuse-tests.js +262 -0
- package/dist/scripts/state-cli-tests.js +226 -101
- package/dist/scripts/state-cli.js +565 -198
- package/dist/scripts/state-repair-cli.js +66 -0
- package/dist/scripts/state-repair-tests.js +140 -0
- package/dist/scripts/static-golden-tests.js +165 -0
- package/dist/scripts/time-eval-tests.js +212 -192
- package/dist/scripts/travel-timeline-tests.js +29 -93
- package/dist/scripts/turn-handoff-collect-tests.js +169 -0
- package/dist/scripts/turn-handoff-collect.js +161 -0
- package/dist/scripts/turn-policy-tests.js +55 -0
- package/dist/scripts/typed-contract-canary.js +176 -0
- package/dist/scripts/tz-resolver-sanity.js +50 -0
- package/dist/scripts/tz-resolver-tests.js +584 -0
- package/dist/scripts/unified-tests.js +60 -20
- package/dist/scripts/visa-policy-tests.js +62 -0
- package/dist/scripts/weather-tests.js +276 -41
- package/dist/scripts/wish-channel-gate-tests.js +54 -0
- package/dist/scripts/write-gate-tests.js +522 -0
- package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
- package/dist/scripts/z3-lifecycle-tests.js +170 -0
- package/dist/scripts/z3-race-repeat-tests.js +39 -0
- package/dist/scripts/z3-race-tests.js +46 -0
- package/dist/src/artifact-gate.js +829 -0
- package/dist/src/backend/kernel.js +91 -0
- package/dist/src/backend/modules/booking-copilot.js +92 -0
- package/dist/src/backend/modules/booking-executor.js +124 -0
- package/dist/src/backend/modules/session-search.js +551 -0
- package/dist/src/benchmark-agent-conformance.js +578 -0
- package/dist/src/benchmark-environment-bridge.js +515 -0
- package/dist/src/benchmark-headless-child-diagnostics.js +166 -0
- package/dist/src/benchmark-tool-isolation.js +151 -0
- package/dist/src/bookable-facts.js +533 -0
- package/dist/src/booking-saga.js +97 -0
- package/dist/src/booking-surface/availability-policy.js +697 -0
- package/dist/src/booking-surface/booking-intent.js +43 -0
- package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
- package/dist/src/booking-surface/canonical-schema.js +116 -0
- package/dist/src/booking-surface/contracts.js +75 -0
- package/dist/src/booking-surface/dsh-planner.js +1854 -0
- package/dist/src/booking-surface/dsh-plugin.js +378 -0
- package/dist/src/booking-surface/dsh-worker.js +98 -0
- package/dist/src/booking-surface/error-codes.js +231 -0
- package/dist/src/booking-surface/index.js +9 -0
- package/dist/src/booking-surface/managed-dsh-cleanup-diagnostic.js +97 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +244 -0
- package/dist/src/booking-surface/profile.js +24 -0
- package/dist/src/booking-surface/recovery-chain.js +367 -0
- package/dist/src/booking-surface/runtime.js +1968 -0
- package/dist/src/booking-surface/server.js +674 -0
- package/dist/src/booking-surface/startup.js +168 -0
- package/dist/src/booking-surface/validation.js +261 -0
- package/dist/src/bridge.js +20 -13
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/companions.js +48 -74
- package/dist/src/contracts.js +21 -43
- package/dist/src/dsh-llm.js +171 -115
- package/dist/src/engine.js +276 -193
- package/dist/src/evaluation-cadence.js +97 -0
- package/dist/src/evaluation-contracts.js +283 -0
- package/dist/src/flight-pack-adapter.js +54 -0
- package/dist/src/flight-pack-contract.js +2 -0
- package/dist/src/flyai-setup-tool.js +70 -0
- package/dist/src/gotry-backend.js +125 -0
- package/dist/src/hotel-date-gate.js +118 -0
- package/dist/src/i18n.js +88 -0
- package/dist/src/index.js +2385 -881
- package/dist/src/itinerary-deck.js +139 -0
- package/dist/src/itinerary-doc-shared.js +661 -0
- package/dist/src/itinerary-html.js +131 -0
- package/dist/src/journey.js +155 -108
- package/dist/src/lavish-tools.js +400 -0
- package/dist/src/ledger-repair-apply.js +538 -0
- package/dist/src/ledger-repair-plan.js +463 -0
- package/dist/src/loop.js +331 -161
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/memory-capture.js +180 -32
- package/dist/src/memory-decay.js +30 -14
- package/dist/src/memory-lifecycle.js +1310 -0
- package/dist/src/memory-utility.js +27 -40
- package/dist/src/mock-llm.js +36 -61
- package/dist/src/model.js +81 -36
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/realtime-pricing.js +103 -0
- package/dist/src/recall/card.js +74 -0
- package/dist/src/recall/evaluator.js +140 -0
- package/dist/src/recall/integration.js +17 -0
- package/dist/src/recall/tick.js +166 -0
- package/dist/src/session-link/plan-it-action.js +48 -0
- package/dist/src/session-link/session-link.js +248 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +510 -0
- package/dist/src/share/adapters.js +51 -0
- package/dist/src/share/share-consent.js +76 -0
- package/dist/src/share/share-deck.js +58 -0
- package/dist/src/share/share-token.js +146 -0
- package/dist/src/slot-spec.js +84 -106
- package/dist/src/state-ledger.js +696 -524
- package/dist/src/state-repair.js +299 -0
- package/dist/src/subagent-job-id-guard.js +58 -0
- package/dist/src/time-anchor.js +231 -33
- package/dist/src/tool-packet.js +20 -8
- package/dist/src/travel-slots.js +58 -35
- package/dist/src/travel-timeline.js +44 -63
- package/dist/src/turn-deadline.js +287 -0
- package/dist/src/turn-policy.js +118 -0
- package/dist/src/tz-resolver.js +143 -0
- package/dist/src/unified.js +600 -360
- package/dist/src/wish-pool.js +42 -12
- package/dist/src/write-gate.js +495 -0
- package/dist/src/z3-shared.js +284 -0
- package/extension/README.md +21 -0
- package/extension/README.zh-CN.md +21 -0
- package/extension/background.js +463 -0
- package/extension/content-bridge.js +270 -0
- package/extension/content-main.js +111 -0
- package/extension/manifest.json +60 -0
- package/package.json +407 -14
- package/schemas/booking.intent.schema.json +193 -0
- package/schemas/booking.surface.schema.json +2622 -0
- package/ts/capabilities/agent-reach-deep.ts +114 -0
- package/ts/capabilities/agent-reach.ts +198 -0
- package/ts/capabilities/anything.ts +282 -0
- package/ts/capabilities/artifacts.ts +428 -0
- package/ts/capabilities/fact-log.ts +54 -0
- package/ts/capabilities/flyai-config.ts +388 -0
- package/ts/capabilities/flyai.ts +1274 -0
- package/ts/capabilities/hbcli.ts +253 -71
- package/ts/capabilities/incident-log.ts +88 -59
- package/ts/capabilities/itinerary-artifact.ts +214 -0
- package/ts/capabilities/itinerary-deck-artifact.ts +218 -0
- package/ts/capabilities/itinerary-deck-export.ts +378 -0
- package/ts/capabilities/opensky.ts +121 -0
- package/ts/capabilities/session/action-cache.ts +148 -0
- package/ts/capabilities/session/adapters/ctrip-flight.ts +181 -0
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/dida-portal.ts +338 -0
- package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +392 -0
- package/ts/capabilities/session/benchmark.ts +276 -0
- package/ts/capabilities/session/bridge-ledger.ts +333 -0
- package/ts/capabilities/session/extension-bridge.ts +794 -0
- package/ts/capabilities/session/extension-channel.ts +144 -0
- package/ts/capabilities/session/extension-distribution.ts +266 -0
- package/ts/capabilities/session/extract.ts +54 -0
- package/ts/capabilities/session/golden-score.ts +139 -0
- package/ts/capabilities/session/health-watch.ts +264 -0
- package/ts/capabilities/session/read-guard.ts +141 -0
- package/ts/capabilities/session/static-flight-golden.ts +209 -0
- package/ts/capabilities/session/transport.ts +146 -0
- package/ts/capabilities/session/wizard.ts +140 -0
- package/ts/capabilities/session-consent.ts +211 -0
- package/ts/capabilities/session-login.ts +208 -0
- package/ts/capabilities/session-search.ts +893 -0
- package/ts/capabilities/weather.ts +330 -0
- package/ts/dsh-runtime/vendor/README.md +68 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +405 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +404 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +1584 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/icon.svg +5 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +595 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +58 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +131 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/geo.js +232 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +120 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +40 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/staticmap-route.js +115 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +267 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +90 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +23 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/geo.d.ts +61 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/staticmap-route.d.ts +31 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +75 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +68 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +97 -0
- package/ts/package.json +293 -3
- package/ts/scripts/async-collect.ts +91 -0
- package/ts/scripts/map-tools-vendor-package-proof.ts +404 -0
- package/ts/scripts/skeleton-check.ts +1 -1
- package/ts/scripts/skeleton-integration-test.ts +23 -1
- package/ts/scripts/state-cli.ts +586 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +991 -0
- package/ts/src/benchmark-agent-conformance.ts +619 -0
- package/ts/src/benchmark-environment-bridge.ts +538 -0
- package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
- package/ts/src/benchmark-tool-isolation.ts +166 -0
- package/ts/src/bookable-facts.ts +837 -0
- package/ts/src/booking-saga.ts +133 -0
- package/ts/src/booking-surface/availability-policy.ts +522 -0
- package/ts/src/booking-surface/booking-intent.ts +86 -0
- package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
- package/ts/src/booking-surface/canonical-schema.js +116 -0
- package/ts/src/booking-surface/contracts.ts +475 -0
- package/ts/src/booking-surface/dsh-planner.ts +1949 -0
- package/ts/src/booking-surface/dsh-plugin.js +378 -0
- package/ts/src/booking-surface/dsh-worker.ts +110 -0
- package/ts/src/booking-surface/error-codes.ts +241 -0
- package/ts/src/booking-surface/index.ts +9 -0
- package/ts/src/booking-surface/managed-dsh-cleanup-diagnostic.ts +151 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +273 -0
- package/ts/src/booking-surface/profile.ts +42 -0
- package/ts/src/booking-surface/recovery-chain.ts +409 -0
- package/ts/src/booking-surface/runtime.ts +1793 -0
- package/ts/src/booking-surface/server.ts +733 -0
- package/ts/src/booking-surface/startup.ts +201 -0
- package/ts/src/booking-surface/validation.ts +217 -0
- package/ts/src/companions.ts +113 -0
- package/ts/src/contracts.ts +17 -3
- package/ts/src/dsh-llm.ts +152 -17
- package/ts/src/flyai-setup-tool.ts +77 -0
- package/ts/src/index.ts +2123 -200
- package/ts/src/itinerary-deck.ts +189 -0
- package/ts/src/itinerary-doc-shared.ts +741 -0
- package/ts/src/itinerary-html.ts +171 -0
- package/ts/src/loop.ts +234 -26
- package/ts/src/memory-capture.ts +283 -0
- package/ts/src/memory-decay.ts +55 -0
- package/ts/src/memory-utility.ts +70 -0
- package/ts/src/mock-llm.ts +4 -8
- package/ts/src/model.ts +88 -2
- package/ts/src/recall/card.ts +106 -0
- package/ts/src/recall/evaluator.ts +170 -0
- package/ts/src/recall/integration.ts +35 -0
- package/ts/src/recall/tick.ts +213 -0
- package/ts/src/session-link/plan-it-action.ts +78 -0
- package/ts/src/session-link/session-link.ts +275 -0
- package/ts/src/share/adapters.ts +93 -0
- package/ts/src/share/share-consent.ts +105 -0
- package/ts/src/share/share-deck.ts +85 -0
- package/ts/src/share/share-token.ts +166 -0
- package/ts/src/slot-spec.ts +172 -0
- package/ts/src/state-ledger.ts +1166 -0
- package/ts/src/subagent-job-id-guard.ts +72 -0
- package/ts/src/time-anchor.ts +338 -0
- package/ts/src/tool-packet.ts +32 -0
- package/ts/src/travel-slots.ts +219 -0
- package/ts/src/travel-timeline.ts +83 -0
- package/ts/src/turn-deadline.ts +366 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/unified.ts +444 -105
- package/ts/src/wish-pool.ts +79 -0
- package/dist/scripts/session-attach-wait.js +0 -41
|
@@ -0,0 +1,1968 @@
|
|
|
1
|
+
/** Durable, task-scoped runtime for the embedded Booking Copilot. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { BOOKING_COPILOT_MAX_OPERATIONS, } from './contracts.js';
|
|
4
|
+
import { validateApprovalAgainstBlocker, validateCriterionBlocker, validateBookingReadAction, validateBookingIntentProjection, validateBookingSurfaceEvent, validateBookingSurface, isBookingDateTime, isSafeBookingRequestKey, } from './validation.js';
|
|
5
|
+
import { assertWorkspaceLoadedOfferRefsUnique, canIssueOfferCheck, createAvailabilityPolicy, reduceAvailabilityAction, reduceAvailabilityReceipt, validateOffersReceiptWorkspace, availabilityPolicyResult, validateAvailabilityPolicy, } from './availability-policy.js';
|
|
6
|
+
import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.js';
|
|
7
|
+
import { BOOKING_INTENT_ACTION_ORDINAL, BOOKING_INTENT_TARGET_ACTION, BOOKING_INTENT_TARGET_ORDINAL, } from './booking-intent.js';
|
|
8
|
+
const LEDGER_SCHEMA = 'booking.copilot.ledger';
|
|
9
|
+
const ACTOR = 'system:booking-copilot';
|
|
10
|
+
const STARTED = 'booking.copilot.task.started';
|
|
11
|
+
const TURN = 'booking.copilot.user.turn.observed';
|
|
12
|
+
const ACTION = 'booking.copilot.action.issued';
|
|
13
|
+
const RECEIPT = 'booking.copilot.receipt.observed';
|
|
14
|
+
const EVENT = 'booking.copilot.event.emitted';
|
|
15
|
+
const APPROVAL_GRANTED = 'booking.copilot.approval.granted';
|
|
16
|
+
const APPROVAL_OFFERED = 'booking.copilot.approval.option.offered';
|
|
17
|
+
const APPROVAL_CONSUMED = 'booking.copilot.approval.consumed';
|
|
18
|
+
const DECISION_BATCH = 'booking.copilot.decision.batch';
|
|
19
|
+
const REQUEST_BINDING = 'booking.copilot.request.binding';
|
|
20
|
+
function stable(value) {
|
|
21
|
+
if (Array.isArray(value))
|
|
22
|
+
return value.map(stable);
|
|
23
|
+
if (!value || typeof value !== 'object')
|
|
24
|
+
return value;
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const key of Object.keys(value).sort())
|
|
27
|
+
out[key] = stable(value[key]);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export function bookingDigest(value) {
|
|
31
|
+
return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
export function bookingWorkspaceDigest(workspace) {
|
|
34
|
+
const { contextRef: _contextRef, ...contextBoundWorkspace } = workspace;
|
|
35
|
+
return bookingDigest(contextBoundWorkspace);
|
|
36
|
+
}
|
|
37
|
+
function bookingWorkspaceSemanticDigest(workspace) {
|
|
38
|
+
const { contextRef: _contextRef, revision: _revision, ...semanticWorkspace } = workspace;
|
|
39
|
+
return bookingDigest(semanticWorkspace);
|
|
40
|
+
}
|
|
41
|
+
export function bookingTurnDigest(turn) {
|
|
42
|
+
return bookingDigest(turn);
|
|
43
|
+
}
|
|
44
|
+
function actionAdvancesIntent(action, intent) {
|
|
45
|
+
// Existing-order observation is an independent surface waypoint. Checkout
|
|
46
|
+
// preparation is deliberately not a prefix: the user-authorized Checkout
|
|
47
|
+
// flow creates a new trusted order context before observation can begin.
|
|
48
|
+
if (intent.target === 'order.observed')
|
|
49
|
+
return action.kind === 'order.observe';
|
|
50
|
+
const actionOrdinal = BOOKING_INTENT_ACTION_ORDINAL[action.kind];
|
|
51
|
+
const targetOrdinal = BOOKING_INTENT_TARGET_ORDINAL[intent.target];
|
|
52
|
+
// Earlier path steps may advance toward a later target; an action after
|
|
53
|
+
// that target is an overshoot and cannot be bound to the same intent.
|
|
54
|
+
if (actionOrdinal === undefined || targetOrdinal === undefined || actionOrdinal > targetOrdinal)
|
|
55
|
+
return false;
|
|
56
|
+
if (intent.offerCriteria && (action.kind === 'offers.query' || action.kind === 'offers.view.patch')) {
|
|
57
|
+
if (bookingDigest(action.input.criteria) !== bookingDigest(intent.offerCriteria))
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (intent.target === 'offers.compared' && action.kind === 'offers.compare') {
|
|
61
|
+
const targetCount = intent.offerCriteria?.targetCount ?? 3;
|
|
62
|
+
if (action.input.requestedCount !== targetCount)
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
function intentCheckpointDigest(value) {
|
|
68
|
+
return bookingDigest(value);
|
|
69
|
+
}
|
|
70
|
+
function validateIntentCheckpoint(checkpoint, state, action) {
|
|
71
|
+
const projection = validateBookingIntentProjection(checkpoint?.projection);
|
|
72
|
+
const { intentDigest: _digest, ...base } = checkpoint ?? {};
|
|
73
|
+
if (!projection.ok
|
|
74
|
+
|| checkpoint.intentDigest !== intentCheckpointDigest(base)
|
|
75
|
+
|| checkpoint.taskId !== state.taskId
|
|
76
|
+
|| checkpoint.contextRef !== state.contextRef
|
|
77
|
+
|| !state.allowedActions.includes(BOOKING_INTENT_TARGET_ACTION[checkpoint.projection.target])
|
|
78
|
+
|| (state.activeIntent
|
|
79
|
+
? bookingDigest(state.activeIntent) !== bookingDigest(checkpoint)
|
|
80
|
+
: checkpoint.sourceTurnId !== action.sourceTurnId
|
|
81
|
+
|| checkpoint.sourceTurnId !== state.lastTurnId
|
|
82
|
+
|| checkpoint.sourceRequestDigest !== state.lastTurnRequestDigest)) {
|
|
83
|
+
throw new Error('intent_checkpoint_invalid');
|
|
84
|
+
}
|
|
85
|
+
if (!actionAdvancesIntent(action, checkpoint.projection))
|
|
86
|
+
throw new Error('intent_checkpoint_invalid');
|
|
87
|
+
}
|
|
88
|
+
function bindIntentCheckpoint(state, action, projection) {
|
|
89
|
+
if (state.activeIntent) {
|
|
90
|
+
if (projection && bookingDigest(projection) !== bookingDigest(state.activeIntent.projection)) {
|
|
91
|
+
throw new Error('intent_mutation_forbidden');
|
|
92
|
+
}
|
|
93
|
+
return state.activeIntent;
|
|
94
|
+
}
|
|
95
|
+
if (!state.lastTurnId || !state.lastTurnRequestDigest)
|
|
96
|
+
throw new Error('intent_source_turn_missing');
|
|
97
|
+
const nextProjection = projection;
|
|
98
|
+
const checked = validateBookingIntentProjection(nextProjection);
|
|
99
|
+
if (!checked.ok)
|
|
100
|
+
throw new Error('invalid_booking_intent');
|
|
101
|
+
if (!state.allowedActions.includes(BOOKING_INTENT_TARGET_ACTION[nextProjection.target]))
|
|
102
|
+
throw new Error('intent_target_unsupported');
|
|
103
|
+
const base = {
|
|
104
|
+
taskId: state.taskId,
|
|
105
|
+
contextRef: state.contextRef,
|
|
106
|
+
sourceTurnId: state.lastTurnId,
|
|
107
|
+
sourceRequestDigest: state.lastTurnRequestDigest,
|
|
108
|
+
projection: JSON.parse(JSON.stringify(nextProjection)),
|
|
109
|
+
};
|
|
110
|
+
return { ...base, intentDigest: intentCheckpointDigest(base) };
|
|
111
|
+
}
|
|
112
|
+
function canonicalReceiptDigest(value) { return bookingDigest(value); }
|
|
113
|
+
function replayDigestMatches(recordedDigest, raw, normalized) {
|
|
114
|
+
return typeof recordedDigest === 'string' && (recordedDigest === bookingDigest(raw) || recordedDigest === bookingDigest(normalized));
|
|
115
|
+
}
|
|
116
|
+
function replayWorkspaceDigestMatches(recordedDigest, raw, normalized) {
|
|
117
|
+
return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceDigest(raw) || recordedDigest === bookingWorkspaceDigest(normalized));
|
|
118
|
+
}
|
|
119
|
+
function replayWorkspaceSemanticDigestMatches(recordedDigest, raw, normalized) {
|
|
120
|
+
return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceSemanticDigest(raw) || recordedDigest === bookingWorkspaceSemanticDigest(normalized));
|
|
121
|
+
}
|
|
122
|
+
function rememberReplayWorkspaceDigest(digests, recordedDigest, raw, normalized) {
|
|
123
|
+
const normalizedDigest = bookingWorkspaceDigest(normalized);
|
|
124
|
+
digests.set(bookingWorkspaceDigest(raw), normalizedDigest);
|
|
125
|
+
if (typeof recordedDigest === 'string')
|
|
126
|
+
digests.set(recordedDigest, normalizedDigest);
|
|
127
|
+
}
|
|
128
|
+
function legacyOfferVersionRef(contextRef, offer) {
|
|
129
|
+
return `legacy-offer-version:${bookingDigest({
|
|
130
|
+
contextRef,
|
|
131
|
+
offerRef: offer.offerRef,
|
|
132
|
+
hotelRef: offer.hotelRef,
|
|
133
|
+
evidenceLevel: offer.evidenceLevel,
|
|
134
|
+
factRefs: Array.isArray(offer.factRefs) ? offer.factRefs : [],
|
|
135
|
+
}).slice(0, 40)}`;
|
|
136
|
+
}
|
|
137
|
+
function normalizeWorkspaceForReplay(raw) {
|
|
138
|
+
const candidate = raw;
|
|
139
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(candidate.loadedOffers) || typeof candidate.contextRef !== 'string')
|
|
140
|
+
throw new Error('booking_malformed_workspace');
|
|
141
|
+
const workspace = structuredClone(raw);
|
|
142
|
+
workspace.loadedOffers = workspace.loadedOffers.map((offer) => offer.offerVersionRef ? offer : { ...offer, offerVersionRef: legacyOfferVersionRef(workspace.contextRef, offer) });
|
|
143
|
+
delete workspace.verifiedOfferRef;
|
|
144
|
+
return workspace;
|
|
145
|
+
}
|
|
146
|
+
function resolveReplayOfferVersion(offerRef, workspace, action, actionId, priorAvailability) {
|
|
147
|
+
const priorAttempt = actionId ? priorAvailability?.attempts.find((attempt) => attempt.actionId === actionId) : undefined;
|
|
148
|
+
if (priorAttempt?.offerVersionRef)
|
|
149
|
+
return priorAttempt.offerVersionRef;
|
|
150
|
+
if (action?.kind === 'offer.check' && action.actionId === actionId && action.input.offerRef === offerRef && typeof action.input.offerVersionRef === 'string')
|
|
151
|
+
return action.input.offerVersionRef;
|
|
152
|
+
const match = workspace?.loadedOffers.find((offer) => offer.offerRef === offerRef);
|
|
153
|
+
if (match?.offerVersionRef)
|
|
154
|
+
return match.offerVersionRef;
|
|
155
|
+
throw new Error('booking_replan_required:offer_version_unbound');
|
|
156
|
+
}
|
|
157
|
+
function normalizeAvailabilityPolicyForReplay(raw, workspace, action, priorAvailability, workspaceDigestMap = new Map(), receipt) {
|
|
158
|
+
const normalized = structuredClone(raw);
|
|
159
|
+
if (normalized.hotels && typeof normalized.hotels === 'object') {
|
|
160
|
+
for (const [hotelRef, hotel] of Object.entries(normalized.hotels)) {
|
|
161
|
+
const rawHotel = raw.hotels?.[hotelRef];
|
|
162
|
+
const priorHotel = priorAvailability?.hotels[hotelRef];
|
|
163
|
+
if (!Array.isArray(hotel.tombstonedOfferRefs))
|
|
164
|
+
hotel.tombstonedOfferRefs = priorHotel ? [...priorHotel.tombstonedOfferRefs] : [];
|
|
165
|
+
if (!Array.isArray(hotel.tombstonedOfferVersionRefs))
|
|
166
|
+
hotel.tombstonedOfferVersionRefs = priorHotel ? [...priorHotel.tombstonedOfferVersionRefs] : [];
|
|
167
|
+
if (rawHotel && Array.isArray(rawHotel.tombstonedOfferRefs) && priorHotel) {
|
|
168
|
+
for (const ref of priorHotel.tombstonedOfferRefs)
|
|
169
|
+
if (!hotel.tombstonedOfferRefs.includes(ref))
|
|
170
|
+
throw new Error('booking_replan_required:tombstone_regression');
|
|
171
|
+
}
|
|
172
|
+
if (rawHotel && Array.isArray(rawHotel.tombstonedOfferVersionRefs) && priorHotel) {
|
|
173
|
+
for (const ref of priorHotel.tombstonedOfferVersionRefs)
|
|
174
|
+
if (!hotel.tombstonedOfferVersionRefs.includes(ref))
|
|
175
|
+
throw new Error('booking_replan_required:tombstone_regression');
|
|
176
|
+
}
|
|
177
|
+
const source = hotel.currentGeneration?.source;
|
|
178
|
+
if (source?.kind === 'workspace_snapshot') {
|
|
179
|
+
const mappedDigest = workspaceDigestMap.get(source.workspaceDigest);
|
|
180
|
+
if (mappedDigest)
|
|
181
|
+
source.workspaceDigest = mappedDigest;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (Array.isArray(normalized.attempts)) {
|
|
186
|
+
normalized.attempts = normalized.attempts.map((attempt) => attempt.offerVersionRef ? attempt : { ...attempt, offerVersionRef: resolveReplayOfferVersion(attempt.offerRef, workspace, action, attempt.actionId, priorAvailability) });
|
|
187
|
+
}
|
|
188
|
+
const availability = receipt?.observation.kind === 'offer.availability' ? receipt.observation : undefined;
|
|
189
|
+
if (receipt && action?.kind === 'offer.check' && availability && availability.offerRef === action.input.offerRef && availability.checkedOfferVersionRef === action.input.offerVersionRef) {
|
|
190
|
+
const attempt = normalized.attempts.find((candidate) => candidate.actionId === action.actionId);
|
|
191
|
+
const hotel = attempt ? normalized.hotels[attempt.hotelRef] : undefined;
|
|
192
|
+
if (hotel) {
|
|
193
|
+
if (receipt.status === 'changed' && availability.available === true && availability.currentOfferVersionRef && availability.currentOfferVersionRef !== action.input.offerVersionRef) {
|
|
194
|
+
if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef))
|
|
195
|
+
hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef);
|
|
196
|
+
}
|
|
197
|
+
const completeNegative = (receipt.status === 'unavailable' || receipt.status === 'no_match')
|
|
198
|
+
&& receipt.resultContract.outcome === 'empty'
|
|
199
|
+
&& receipt.resultContract.gapCodes.length === 0
|
|
200
|
+
&& receipt.resultContract.blockers.length === 0
|
|
201
|
+
&& availability.available === false
|
|
202
|
+
&& !availability.currentOfferVersionRef
|
|
203
|
+
&& !availability.verifiedOfferRef
|
|
204
|
+
&& !availability.gapCodes?.length;
|
|
205
|
+
if (completeNegative) {
|
|
206
|
+
if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef))
|
|
207
|
+
hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef);
|
|
208
|
+
if (!hotel.tombstonedOfferRefs.includes(action.input.offerRef))
|
|
209
|
+
hotel.tombstonedOfferRefs.push(action.input.offerRef);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return normalized;
|
|
214
|
+
}
|
|
215
|
+
function normalizeActionCheckpointForReplay(raw, workspace) {
|
|
216
|
+
if (!['offer.select', 'offer.check', 'checkout.prepare'].includes(raw.kind) || typeof raw.input.offerVersionRef === 'string')
|
|
217
|
+
return raw;
|
|
218
|
+
if (typeof raw.input.offerRef !== 'string')
|
|
219
|
+
throw new Error('booking_replan_required:offer_version_unbound');
|
|
220
|
+
const offerVersionRef = resolveReplayOfferVersion(raw.input.offerRef, workspace, undefined, raw.actionId);
|
|
221
|
+
const input = { ...raw.input, offerVersionRef };
|
|
222
|
+
if (raw.kind === 'checkout.prepare' && (!workspace?.verifiedOffer || workspace.verifiedOffer.offerRef !== input.offerRef || workspace.verifiedOffer.offerVersionRef !== offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== input.verifiedOfferRef))
|
|
223
|
+
throw new Error('booking_replan_required:legacy_checkout_unverified');
|
|
224
|
+
const { actionDigest: _rawActionDigest, ...rawBase } = raw;
|
|
225
|
+
const normalizedBase = { ...rawBase, input, inputDigest: bookingDigest(input) };
|
|
226
|
+
return { ...normalizedBase, actionDigest: checkpointDigest(normalizedBase) };
|
|
227
|
+
}
|
|
228
|
+
function checkpointDigestMatchesForReplay(raw, normalized) {
|
|
229
|
+
const { actionDigest: _rawDigest, ...rawBase } = raw;
|
|
230
|
+
const { actionDigest: _normalizedDigest, ...normalizedBase } = normalized;
|
|
231
|
+
return raw.actionDigest === checkpointDigest(rawBase) || raw.actionDigest === checkpointDigest(normalizedBase);
|
|
232
|
+
}
|
|
233
|
+
function replanAfterReplayUpgradeGap(state, workspace = state.workspaceSnapshot, revision = workspace?.revision) {
|
|
234
|
+
if (!workspace)
|
|
235
|
+
return { ...state, phase: 'error', pendingAction: undefined };
|
|
236
|
+
const safeRevision = typeof revision === 'number' && Number.isSafeInteger(revision) ? revision : state.revision;
|
|
237
|
+
return {
|
|
238
|
+
...state,
|
|
239
|
+
revision: safeRevision,
|
|
240
|
+
phase: 'planning',
|
|
241
|
+
pendingAction: undefined,
|
|
242
|
+
lastCompletedAction: undefined,
|
|
243
|
+
lastReceipt: undefined,
|
|
244
|
+
activeIntent: undefined,
|
|
245
|
+
awaitingApproval: undefined,
|
|
246
|
+
workspaceDigest: bookingWorkspaceDigest(workspace),
|
|
247
|
+
workspaceSemanticDigest: bookingWorkspaceSemanticDigest(workspace),
|
|
248
|
+
workspaceSnapshot: workspace,
|
|
249
|
+
availability: createAvailabilityPolicy(workspace),
|
|
250
|
+
replayUpgradeRequired: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function assertReplayUpgradeReanchored(state) {
|
|
254
|
+
if (state.replayUpgradeRequired)
|
|
255
|
+
throw new Error('reanchor_turn_required');
|
|
256
|
+
}
|
|
257
|
+
function consumedIdentityKey(eventId, sequence) { return `${eventId}\0${sequence}`; }
|
|
258
|
+
function clearPendingBatch(consumed) {
|
|
259
|
+
consumed.pendingBatchKeys.clear();
|
|
260
|
+
consumed.pendingBatchOpen = false;
|
|
261
|
+
}
|
|
262
|
+
function rememberPendingBatchKey(consumed, key) {
|
|
263
|
+
consumed.pendingBatchKeys.add(key);
|
|
264
|
+
}
|
|
265
|
+
function rememberConsumedEventIdentity(consumed, payload) {
|
|
266
|
+
if (payload.eventKind === 'status' && (payload.status === 'submitted' || payload.status === 'working') && !consumed.pendingBatchOpen) {
|
|
267
|
+
clearPendingBatch(consumed);
|
|
268
|
+
consumed.pendingBatchOpen = true;
|
|
269
|
+
}
|
|
270
|
+
const key = consumedIdentityKey(payload.eventId, payload.sequence);
|
|
271
|
+
consumed.events.set(key, payload);
|
|
272
|
+
rememberPendingBatchKey(consumed, key);
|
|
273
|
+
}
|
|
274
|
+
function rememberConsumedActionIdentity(consumed, action) {
|
|
275
|
+
const key = consumedIdentityKey(action.eventId, action.sequence);
|
|
276
|
+
consumed.actions.set(key, action);
|
|
277
|
+
rememberPendingBatchKey(consumed, key);
|
|
278
|
+
}
|
|
279
|
+
function consumeSkippedActionOrdinal(state, payload, consumed) {
|
|
280
|
+
const action = payload.action;
|
|
281
|
+
const operationCount = payload.operationCount ?? state.operationCount + 1;
|
|
282
|
+
if (!action || typeof action !== 'object')
|
|
283
|
+
throw new Error('skip_action_invalid');
|
|
284
|
+
const { actionDigest: _actionDigest, ...actionBase } = action;
|
|
285
|
+
if (action.actionDigest !== checkpointDigest(actionBase) || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !Number.isSafeInteger(action.sequence) || action.sequence <= state.lastSequence)
|
|
286
|
+
throw new Error('skip_action_invalid');
|
|
287
|
+
state.operationCount = operationCount;
|
|
288
|
+
state.lastSequence = action.sequence;
|
|
289
|
+
if (consumed) {
|
|
290
|
+
rememberConsumedActionIdentity(consumed, action);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function eventDraftDigest(event) {
|
|
294
|
+
if (event.kind === 'status')
|
|
295
|
+
return bookingDigest({ kind: event.kind, status: event.status });
|
|
296
|
+
if (event.kind === 'question')
|
|
297
|
+
return bookingDigest({ kind: event.kind, question: event.question });
|
|
298
|
+
if (event.kind === 'explanation')
|
|
299
|
+
return bookingDigest({ kind: event.kind, explanation: event.explanation });
|
|
300
|
+
if (event.kind === 'terminal')
|
|
301
|
+
return bookingDigest({ kind: event.kind, terminal: event.terminal });
|
|
302
|
+
return bookingDigest({ kind: event.kind, error: event.error });
|
|
303
|
+
}
|
|
304
|
+
function eventPayloadMatchesEvent(payload, event) {
|
|
305
|
+
const expectedDigest = eventDraftDigest(event);
|
|
306
|
+
const legacyTerminalDigest = event.kind === 'terminal' ? bookingDigest(event.terminal) : undefined;
|
|
307
|
+
return payload.eventId === event.eventId
|
|
308
|
+
&& payload.sequence === event.sequence
|
|
309
|
+
&& payload.emittedAt === event.emittedAt
|
|
310
|
+
&& payload.contextRef === event.contextRef
|
|
311
|
+
&& payload.eventKind === event.kind
|
|
312
|
+
&& (event.kind !== 'status' || payload.status === event.status)
|
|
313
|
+
&& (payload.contentDigest === expectedDigest || payload.contentDigest === legacyTerminalDigest);
|
|
314
|
+
}
|
|
315
|
+
function actionFromOperationEventForReplay(event, checkpoint) {
|
|
316
|
+
const action = event.action;
|
|
317
|
+
if ((action.kind === 'offer.select' || action.kind === 'offer.check' || action.kind === 'checkout.prepare') && typeof action.input.offerRef === 'string' && typeof action.input.offerVersionRef !== 'string' && typeof checkpoint.input.offerVersionRef === 'string') {
|
|
318
|
+
return { ...action, input: { ...action.input, offerVersionRef: checkpoint.input.offerVersionRef } };
|
|
319
|
+
}
|
|
320
|
+
if ((action.kind === 'offer.select' || action.kind === 'offer.check' || action.kind === 'checkout.prepare') && typeof action.input.offerRef === 'string' && typeof action.input.offerVersionRef === 'string' && typeof checkpoint.input.offerVersionRef !== 'string') {
|
|
321
|
+
// A pre-versioned ACTION row is the authority during a skipped legacy
|
|
322
|
+
// tail. Its duplicate decision envelope may still contain a newer
|
|
323
|
+
// offerVersionRef field. Ignore that unbound field for envelope identity;
|
|
324
|
+
// the action itself is never resumed and a fresh intent/turn is required.
|
|
325
|
+
const { offerVersionRef: _unboundVersion, ...legacyInput } = action.input;
|
|
326
|
+
return { ...action, input: legacyInput };
|
|
327
|
+
}
|
|
328
|
+
return action;
|
|
329
|
+
}
|
|
330
|
+
function operationEventMatchesAction(event, checkpoint) {
|
|
331
|
+
return event.eventId === checkpoint.eventId
|
|
332
|
+
&& event.sequence === checkpoint.sequence
|
|
333
|
+
&& event.emittedAt === checkpoint.emittedAt
|
|
334
|
+
&& event.contextRef === checkpoint.contextRef
|
|
335
|
+
&& matchesCheckpoint(actionFromOperationEventForReplay(event, checkpoint), checkpoint);
|
|
336
|
+
}
|
|
337
|
+
function normalizeDecisionBatchEnvelopeForReplay(payload, consumed, requireSchemaValid = false) {
|
|
338
|
+
if (typeof payload.requestKey !== 'string' || !payload.requestKey || !payload.events.length)
|
|
339
|
+
throw new Error('skip_decision_batch_invalid');
|
|
340
|
+
let lastBatchSequence = -1;
|
|
341
|
+
const seen = new Set();
|
|
342
|
+
const normalizedEvents = [];
|
|
343
|
+
for (const event of payload.events) {
|
|
344
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence)
|
|
345
|
+
throw new Error('skip_decision_batch_invalid');
|
|
346
|
+
lastBatchSequence = event.sequence;
|
|
347
|
+
const key = consumedIdentityKey(event.eventId, event.sequence);
|
|
348
|
+
if (seen.has(key))
|
|
349
|
+
throw new Error('skip_decision_batch_invalid');
|
|
350
|
+
seen.add(key);
|
|
351
|
+
if (event.kind === 'operation') {
|
|
352
|
+
const checkpoint = consumed.actions.get(key);
|
|
353
|
+
if (!checkpoint || !operationEventMatchesAction(event, checkpoint))
|
|
354
|
+
throw new Error('skip_decision_batch_invalid');
|
|
355
|
+
const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) };
|
|
356
|
+
if (requireSchemaValid && !validateBookingSurfaceEvent(normalized).ok)
|
|
357
|
+
throw new Error('skip_decision_batch_invalid');
|
|
358
|
+
normalizedEvents.push(normalized);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
const recorded = consumed.events.get(key);
|
|
362
|
+
if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event))
|
|
363
|
+
throw new Error('skip_decision_batch_invalid');
|
|
364
|
+
normalizedEvents.push(event);
|
|
365
|
+
}
|
|
366
|
+
if (!consumed.pendingBatchKeys.has(key))
|
|
367
|
+
throw new Error('skip_decision_batch_invalid');
|
|
368
|
+
}
|
|
369
|
+
if (seen.size !== consumed.pendingBatchKeys.size)
|
|
370
|
+
throw new Error('skip_decision_batch_invalid');
|
|
371
|
+
for (const key of consumed.pendingBatchKeys)
|
|
372
|
+
if (!seen.has(key))
|
|
373
|
+
throw new Error('skip_decision_batch_invalid');
|
|
374
|
+
clearPendingBatch(consumed);
|
|
375
|
+
return normalizedEvents;
|
|
376
|
+
}
|
|
377
|
+
function consumeSkippedEventSequence(state, payload, consumed) {
|
|
378
|
+
if (!payload || typeof payload !== 'object')
|
|
379
|
+
throw new Error('skip_event_invalid');
|
|
380
|
+
if ('sequence' in payload) {
|
|
381
|
+
const eventPayload = payload;
|
|
382
|
+
if (typeof eventPayload.eventId !== 'string' || !eventPayload.eventId || !['status', 'question', 'explanation', 'terminal', 'error'].includes(String(eventPayload.eventKind)) || typeof eventPayload.contentDigest !== 'string' || !/^[0-9a-f]{64}$/.test(eventPayload.contentDigest) || typeof eventPayload.sequence !== 'number' || !Number.isSafeInteger(eventPayload.sequence) || eventPayload.sequence <= state.lastSequence)
|
|
383
|
+
throw new Error('skip_event_invalid');
|
|
384
|
+
state.lastSequence = eventPayload.sequence;
|
|
385
|
+
rememberConsumedEventIdentity(consumed, eventPayload);
|
|
386
|
+
}
|
|
387
|
+
else if ('events' in payload && Array.isArray(payload.events)) {
|
|
388
|
+
normalizeDecisionBatchEnvelopeForReplay(payload, consumed);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function normalizeReceiptForReplay(raw, action, workspace) {
|
|
392
|
+
const receipt = structuredClone(raw);
|
|
393
|
+
if (action.kind === 'offer.select' && receipt.observation.kind === 'offer.selection' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
|
|
394
|
+
receipt.observation.offerVersionRef = action.input.offerVersionRef;
|
|
395
|
+
}
|
|
396
|
+
if (action.kind === 'checkout.prepare' && receipt.observation.kind === 'checkout.handoff' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
|
|
397
|
+
if (!workspace.verifiedOffer || workspace.verifiedOffer.offerRef !== action.input.offerRef || workspace.verifiedOffer.offerVersionRef !== action.input.offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== action.input.verifiedOfferRef)
|
|
398
|
+
throw new Error('booking_replan_required:legacy_checkout_unverified');
|
|
399
|
+
receipt.observation.offerVersionRef = action.input.offerVersionRef;
|
|
400
|
+
}
|
|
401
|
+
if (action.kind === 'offer.check' && receipt.observation.kind === 'offer.availability' && receipt.observation.offerRef === action.input.offerRef) {
|
|
402
|
+
if (!receipt.observation.checkedOfferVersionRef)
|
|
403
|
+
receipt.observation.checkedOfferVersionRef = action.input.offerVersionRef;
|
|
404
|
+
if (receipt.status === 'applied' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef)
|
|
405
|
+
receipt.observation.currentOfferVersionRef = action.input.offerVersionRef;
|
|
406
|
+
if (receipt.status === 'changed' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) {
|
|
407
|
+
const current = workspace.loadedOffers.find((offer) => offer.offerRef === action.input.offerRef && offer.offerVersionRef !== action.input.offerVersionRef);
|
|
408
|
+
if (!current)
|
|
409
|
+
throw new Error('booking_replan_required:changed_version_unbound');
|
|
410
|
+
receipt.observation.currentOfferVersionRef = current.offerVersionRef;
|
|
411
|
+
}
|
|
412
|
+
if (receipt.status === 'applied' && receipt.observation.verifiedOfferRef && (!workspace.verifiedOffer || workspace.verifiedOffer.offerRef !== action.input.offerRef || workspace.verifiedOffer.offerVersionRef !== action.input.offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== receipt.observation.verifiedOfferRef))
|
|
413
|
+
throw new Error('booking_replan_required:legacy_verified_scalar');
|
|
414
|
+
}
|
|
415
|
+
if (receipt.resultContract.blockers.length && bookingDigest(raw) !== bookingDigest(receipt))
|
|
416
|
+
throw new Error('booking_replan_required:legacy_blocker_digest');
|
|
417
|
+
return receipt;
|
|
418
|
+
}
|
|
419
|
+
function errorText(result) { return result.ok ? '' : result.errors.join('; '); }
|
|
420
|
+
function assertTaskId(taskId) { if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(taskId))
|
|
421
|
+
throw new Error('invalid_task_id'); }
|
|
422
|
+
function assertSafeRef(value) { if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value))
|
|
423
|
+
throw new Error('unsafe_opaque_ref'); }
|
|
424
|
+
function safeIdentity(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value); }
|
|
425
|
+
function sameActions(a, b) { return a.length === b.length && [...a].sort().every((x, i) => x === [...b].sort()[i]); }
|
|
426
|
+
function availabilityPolicyTerminal(state) { return availabilityPolicyResult(state); }
|
|
427
|
+
function availabilityTerminalEndsTask(terminal) {
|
|
428
|
+
return Boolean(terminal && terminal.code !== 'availability_confirmed');
|
|
429
|
+
}
|
|
430
|
+
function taskHasTerminalBudget(state) {
|
|
431
|
+
return state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS || availabilityTerminalEndsTask(state.availability.terminal);
|
|
432
|
+
}
|
|
433
|
+
function assertRequestKey(requestKey) {
|
|
434
|
+
if (!isSafeBookingRequestKey(requestKey))
|
|
435
|
+
throw new Error('unsafe_request_key');
|
|
436
|
+
}
|
|
437
|
+
/** Decision batches are runtime-owned and may contain long task/turn/action refs. */
|
|
438
|
+
function assertInternalDecisionKey(requestKey) {
|
|
439
|
+
if (typeof requestKey !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,2047}$/.test(requestKey))
|
|
440
|
+
throw new Error('unsafe_internal_decision_key');
|
|
441
|
+
}
|
|
442
|
+
function progressDecisionKey(requestKey) {
|
|
443
|
+
return `progress:${bookingDigest(requestKey)}`;
|
|
444
|
+
}
|
|
445
|
+
function assertDecisionBatchFinality(decisions) {
|
|
446
|
+
let finalSeen = false;
|
|
447
|
+
for (const decision of decisions) {
|
|
448
|
+
const isFinal = decision.kind === 'terminal' || decision.kind === 'error';
|
|
449
|
+
if (finalSeen)
|
|
450
|
+
throw new Error('decision_batch_finality');
|
|
451
|
+
if (isFinal)
|
|
452
|
+
finalSeen = true;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function assertPrincipal(principal) {
|
|
456
|
+
if (!principal || typeof principal.subject !== 'string' || !principal.subject || principal.subject.length > 256 || typeof principal.scope !== 'string' || !principal.scope || principal.scope.length > 256)
|
|
457
|
+
throw new Error('trusted_principal_required');
|
|
458
|
+
}
|
|
459
|
+
function actionAllowedAfterAvailabilityConfirmed(state, action, now) {
|
|
460
|
+
const confirmed = state.lastReceipt;
|
|
461
|
+
return state.availability.terminal?.code === 'availability_confirmed'
|
|
462
|
+
&& confirmed?.status === 'applied'
|
|
463
|
+
&& confirmed.observation.kind === 'offer.availability'
|
|
464
|
+
&& confirmed.observation.available === true
|
|
465
|
+
&& confirmed.observation.currentOfferVersionRef === confirmed.observation.checkedOfferVersionRef
|
|
466
|
+
&& action.kind === 'checkout.prepare'
|
|
467
|
+
&& action.input.offerRef === confirmed.observation.offerRef
|
|
468
|
+
&& action.input.offerVersionRef === confirmed.observation.checkedOfferVersionRef
|
|
469
|
+
&& action.input.verifiedOfferRef === confirmed.observation.verifiedOfferRef
|
|
470
|
+
&& Boolean(state.workspaceSnapshot && actionHitsCurrentOfferVersion(state.workspaceSnapshot, action, now));
|
|
471
|
+
}
|
|
472
|
+
function assertAvailabilityActionCompatible(state, action, now) {
|
|
473
|
+
if (!state.availability.recoveryStarted)
|
|
474
|
+
return;
|
|
475
|
+
if (!state.workspaceSnapshot)
|
|
476
|
+
throw new Error('availability_operation_required');
|
|
477
|
+
if (state.availability.terminal?.code === 'availability_confirmed') {
|
|
478
|
+
if (!actionAllowedAfterAvailabilityConfirmed(state, action, now))
|
|
479
|
+
throw new Error('availability_operation_incompatible');
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (state.availability.terminal)
|
|
483
|
+
throw new Error('availability_operation_incompatible');
|
|
484
|
+
const activeHotelRef = state.availability.hotelRefs[state.availability.activeHotelOrdinal];
|
|
485
|
+
if (!activeHotelRef)
|
|
486
|
+
throw new Error('availability_active_hotel_missing');
|
|
487
|
+
if (state.availability.availabilityPhase === 'need_offers') {
|
|
488
|
+
if (action.kind !== 'offers.query' || action.input.hotelRefs.length !== 1 || action.input.hotelRefs[0] !== activeHotelRef)
|
|
489
|
+
throw new Error('availability_operation_incompatible');
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (state.availability.availabilityPhase === 'need_check') {
|
|
493
|
+
if (action.kind !== 'offer.check' || !state.availability.hotels[activeHotelRef]?.currentOfferRefs.includes(action.input.offerRef) || !canIssueOfferCheck(state.availability, state.workspaceSnapshot, action.input.offerRef, action.input.offerVersionRef).ok)
|
|
494
|
+
throw new Error('availability_operation_incompatible');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
throw new Error('availability_operation_incompatible');
|
|
498
|
+
}
|
|
499
|
+
function checkoutReceiptPreparedHandoff(receipt) {
|
|
500
|
+
return receipt.status === 'applied'
|
|
501
|
+
&& receipt.observation.kind === 'checkout.handoff'
|
|
502
|
+
&& receipt.resultContract.outcome === 'complete'
|
|
503
|
+
&& receipt.resultContract.hardCriteriaMet
|
|
504
|
+
&& receipt.resultContract.gapCodes.length === 0
|
|
505
|
+
&& receipt.resultContract.blockers.length === 0;
|
|
506
|
+
}
|
|
507
|
+
function assertAvailabilityLiveness(state, decisions, now) {
|
|
508
|
+
if (!state.availability.recoveryStarted)
|
|
509
|
+
return;
|
|
510
|
+
const operations = decisions.filter((decision) => decision.kind === 'operation');
|
|
511
|
+
if (state.availability.terminal?.code === 'availability_confirmed') {
|
|
512
|
+
if (!state.workspaceSnapshot)
|
|
513
|
+
throw new Error('availability_operation_required');
|
|
514
|
+
if (operations.length === 0) {
|
|
515
|
+
const last = decisions.at(-1);
|
|
516
|
+
if (!last || !['terminal', 'error'].includes(last.kind) || decisions.some((decision) => decision.kind !== 'explanation' && decision.kind !== 'terminal' && decision.kind !== 'error'))
|
|
517
|
+
throw new Error('availability_operation_required');
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (operations.length !== 1 || decisions.length !== 1)
|
|
521
|
+
throw new Error('availability_operation_required');
|
|
522
|
+
assertAvailabilityActionCompatible(state, operations[0].action, now);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (operations.length !== 1)
|
|
526
|
+
throw new Error('availability_operation_required');
|
|
527
|
+
assertAvailabilityActionCompatible(state, operations[0].action, now);
|
|
528
|
+
}
|
|
529
|
+
function checkpointDigest(action) {
|
|
530
|
+
return bookingDigest(action);
|
|
531
|
+
}
|
|
532
|
+
function checkpointFor(action, eventId, sequence, emittedAt, sourceTurnId) {
|
|
533
|
+
const base = {
|
|
534
|
+
actionId: action.actionId,
|
|
535
|
+
kind: action.kind,
|
|
536
|
+
contextRef: action.contextRef,
|
|
537
|
+
expectedRevision: action.expectedRevision,
|
|
538
|
+
factRefs: [...action.factRefs],
|
|
539
|
+
reasonDigest: bookingDigest(action.reason),
|
|
540
|
+
inputDigest: bookingDigest(action.input),
|
|
541
|
+
input: action.input,
|
|
542
|
+
eventId,
|
|
543
|
+
sequence,
|
|
544
|
+
emittedAt,
|
|
545
|
+
sourceTurnId,
|
|
546
|
+
...(action.relaxationApprovalRef ? { relaxationApprovalRef: action.relaxationApprovalRef } : {}),
|
|
547
|
+
};
|
|
548
|
+
return { ...base, actionDigest: checkpointDigest(base) };
|
|
549
|
+
}
|
|
550
|
+
function matchesCheckpoint(action, checkpoint) {
|
|
551
|
+
const candidate = checkpointFor(action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef }, checkpoint.eventId, checkpoint.sequence, checkpoint.emittedAt, checkpoint.sourceTurnId);
|
|
552
|
+
const { actionDigest: _ignored, ...candidateBase } = candidate;
|
|
553
|
+
return checkpoint.actionDigest === checkpointDigest(candidateBase);
|
|
554
|
+
}
|
|
555
|
+
function receiptSourceDigest(receipt) {
|
|
556
|
+
return bookingDigest({
|
|
557
|
+
...receipt,
|
|
558
|
+
resultContract: {
|
|
559
|
+
...receipt.resultContract,
|
|
560
|
+
blockers: receipt.resultContract.blockers.map(({ sourceReceiptDigest: _ignored, ...blocker }) => blocker),
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
function assertCanonicalReceipt(receipt) {
|
|
565
|
+
const expected = receiptSourceDigest(receipt);
|
|
566
|
+
if (receipt.resultContract.blockers.some((blocker) => blocker.sourceReceiptDigest !== expected))
|
|
567
|
+
throw new Error('receipt_source_digest_mismatch');
|
|
568
|
+
}
|
|
569
|
+
function receiptObservationMatchesAction(observation, action) {
|
|
570
|
+
if (observation === 'gap')
|
|
571
|
+
return true;
|
|
572
|
+
const expected = {
|
|
573
|
+
'search.patch': 'search.state', 'search.run': 'search.state', 'results.view.patch': 'results.state',
|
|
574
|
+
'hotel.focus': 'hotel.focus', 'hotel.select': 'hotel.selection', 'offers.query': 'offers.state',
|
|
575
|
+
'offers.view.patch': 'offers.state', 'offers.compare': 'offers.state', 'offer.select': 'offer.selection',
|
|
576
|
+
'offer.check': 'offer.availability', 'checkout.prepare': 'checkout.handoff', 'order.observe': 'order.state',
|
|
577
|
+
};
|
|
578
|
+
return expected[action] === observation;
|
|
579
|
+
}
|
|
580
|
+
function sameRefSet(a, b) {
|
|
581
|
+
return a.length === b.length && [...a].sort().every((value, index) => value === [...b].sort()[index]);
|
|
582
|
+
}
|
|
583
|
+
function receiptTargetMatchesAction(receipt, action) {
|
|
584
|
+
const observation = receipt.observation;
|
|
585
|
+
const input = action.input;
|
|
586
|
+
if (action.kind === 'hotel.focus' && observation.kind === 'hotel.focus')
|
|
587
|
+
return observation.hotelRef === input.hotelRef;
|
|
588
|
+
if (action.kind === 'hotel.select' && observation.kind === 'hotel.selection')
|
|
589
|
+
return observation.hotelRef === input.hotelRef;
|
|
590
|
+
if (action.kind === 'offer.select' && observation.kind === 'offer.selection')
|
|
591
|
+
return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef;
|
|
592
|
+
if (action.kind === 'offer.check' && observation.kind === 'offer.availability')
|
|
593
|
+
return observation.offerRef === input.offerRef && observation.checkedOfferVersionRef === input.offerVersionRef;
|
|
594
|
+
if (action.kind === 'checkout.prepare' && observation.kind === 'checkout.handoff')
|
|
595
|
+
return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef && observation.verifiedOfferRef === input.verifiedOfferRef;
|
|
596
|
+
if (action.kind === 'offers.query' && observation.kind === 'offers.state')
|
|
597
|
+
return sameRefSet(observation.hotelRefs, input.hotelRefs);
|
|
598
|
+
if (action.kind === 'offers.view.patch' && observation.kind === 'offers.state')
|
|
599
|
+
return observation.hotelRefs.includes(input.hotelRef);
|
|
600
|
+
if (action.kind === 'offers.compare' && observation.kind === 'offers.state') {
|
|
601
|
+
return observation.offerRefs.every((offerRef) => input.offerRefs.includes(offerRef))
|
|
602
|
+
&& receipt.resultContract.requestedCount === input.requestedCount
|
|
603
|
+
&& receipt.resultContract.actualCount === observation.offerRefs.length;
|
|
604
|
+
}
|
|
605
|
+
if (action.kind === 'order.observe' && observation.kind === 'order.state')
|
|
606
|
+
return observation.orderRef === input.orderRef;
|
|
607
|
+
return observation.kind === 'gap' || action.kind === 'search.patch' || action.kind === 'search.run' || action.kind === 'results.view.patch';
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* An offers.state receipt is only executable evidence when it is reconciled
|
|
611
|
+
* against the page's post-action loaded offer registry. The planner may
|
|
612
|
+
* propose the receipt shape, but it cannot create offer refs or counts that
|
|
613
|
+
* are absent from that registry.
|
|
614
|
+
*/
|
|
615
|
+
function assertOffersReceiptWorkspaceBinding(receipt, workspace, action) {
|
|
616
|
+
if (receipt.observation.kind !== 'offers.state')
|
|
617
|
+
return;
|
|
618
|
+
if (action.kind !== 'offers.query' && action.kind !== 'offers.view.patch')
|
|
619
|
+
return;
|
|
620
|
+
const hotelRefs = action.kind === 'offers.query' ? action.input.hotelRefs : [action.input.hotelRef];
|
|
621
|
+
validateOffersReceiptWorkspace(receipt, workspace, hotelRefs);
|
|
622
|
+
if (receipt.resultContract.actualCount !== undefined
|
|
623
|
+
&& receipt.resultContract.actualCount !== receipt.observation.offerRefs.length) {
|
|
624
|
+
throw new Error('availability_offers_count_mismatch');
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function workspaceBoundaryMatches(a, b) {
|
|
628
|
+
return a.locale === b.locale && a.currency === b.currency && a.surface === b.surface && a.contextRef === b.contextRef && a.capabilities.surface === b.capabilities.surface && sameActions(a.capabilities.allowedActions, b.capabilities.allowedActions);
|
|
629
|
+
}
|
|
630
|
+
function workspaceHasOfferVersion(workspace, offerRef, offerVersionRef) {
|
|
631
|
+
return workspace.loadedOffers.some((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef);
|
|
632
|
+
}
|
|
633
|
+
function verifiedOfferMatches(workspace, input) {
|
|
634
|
+
return workspace.verifiedOffer?.offerRef === input.offerRef && workspace.verifiedOffer.offerVersionRef === input.offerVersionRef && workspace.verifiedOffer.verifiedOfferRef === input.verifiedOfferRef;
|
|
635
|
+
}
|
|
636
|
+
function verifiedOfferUnexpired(workspace, now) {
|
|
637
|
+
return Boolean(workspace.verifiedOffer && isBookingDateTime(workspace.verifiedOffer.expiresAt) && Date.parse(workspace.verifiedOffer.expiresAt) > Date.parse(now));
|
|
638
|
+
}
|
|
639
|
+
function actionHitsCurrentOfferVersion(workspace, action, now) {
|
|
640
|
+
if (action.kind === 'offer.select' || action.kind === 'offer.check')
|
|
641
|
+
return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef);
|
|
642
|
+
if (action.kind === 'checkout.prepare')
|
|
643
|
+
return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef) && verifiedOfferMatches(workspace, action.input) && (now === undefined || verifiedOfferUnexpired(workspace, now));
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
function shortlistAfterCheckedOffer(previous, current, checkedOfferRef) {
|
|
647
|
+
if (sameActions(previous, current))
|
|
648
|
+
return true;
|
|
649
|
+
const removedChecked = previous.filter((ref) => ref !== checkedOfferRef);
|
|
650
|
+
return removedChecked.length === previous.length - 1 && sameActions(removedChecked, current);
|
|
651
|
+
}
|
|
652
|
+
function loadedOffersUnchanged(previous, current) {
|
|
653
|
+
return sameActions(previous.loadedOffers.map((offer) => bookingDigest(offer)), current.loadedOffers.map((offer) => bookingDigest(offer)));
|
|
654
|
+
}
|
|
655
|
+
function changedFactRefsMatch(previousFactRefs, currentFactRefs, changedFactRefs) {
|
|
656
|
+
if (!changedFactRefs.length)
|
|
657
|
+
return false;
|
|
658
|
+
const before = new Set(previousFactRefs);
|
|
659
|
+
const after = new Set(currentFactRefs);
|
|
660
|
+
const diff = [
|
|
661
|
+
...previousFactRefs.filter((ref) => !after.has(ref)),
|
|
662
|
+
...currentFactRefs.filter((ref) => !before.has(ref)),
|
|
663
|
+
];
|
|
664
|
+
return sameActions(diff, changedFactRefs);
|
|
665
|
+
}
|
|
666
|
+
function loadedOffersChangedVersionOnly(previous, current, offerRef, currentOfferVersionRef, changedFactRefs) {
|
|
667
|
+
if (previous.loadedOffers.length !== current.loadedOffers.length)
|
|
668
|
+
return false;
|
|
669
|
+
const previousChecked = previous.loadedOffers.find((offer) => offer.offerRef === offerRef);
|
|
670
|
+
const currentChecked = current.loadedOffers.find((offer) => offer.offerRef === offerRef);
|
|
671
|
+
if (!previousChecked || !currentChecked || previousChecked.hotelRef !== currentChecked.hotelRef || currentChecked.offerVersionRef !== currentOfferVersionRef || previousChecked.offerVersionRef === currentChecked.offerVersionRef)
|
|
672
|
+
return false;
|
|
673
|
+
if (previousChecked.evidenceLevel !== currentChecked.evidenceLevel)
|
|
674
|
+
return false;
|
|
675
|
+
if (!changedFactRefsMatch(previousChecked.factRefs, currentChecked.factRefs, changedFactRefs))
|
|
676
|
+
return false;
|
|
677
|
+
const previousOther = previous.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingDigest(offer));
|
|
678
|
+
const currentOther = current.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingDigest(offer));
|
|
679
|
+
return sameActions(previousOther, currentOther);
|
|
680
|
+
}
|
|
681
|
+
function availabilityReceiptIsComplete(receipt) {
|
|
682
|
+
return receipt.resultContract.outcome === 'complete'
|
|
683
|
+
&& receipt.resultContract.hardCriteriaMet
|
|
684
|
+
&& receipt.resultContract.gapCodes.length === 0
|
|
685
|
+
&& receipt.resultContract.blockers.length === 0;
|
|
686
|
+
}
|
|
687
|
+
function offerCheckPostActionMatches(previous, current, action, receipt) {
|
|
688
|
+
const checkedOfferRef = action.input.offerRef;
|
|
689
|
+
const checkedOfferVersionRef = action.input.offerVersionRef;
|
|
690
|
+
const observation = receipt.observation.kind === 'offer.availability' ? receipt.observation : undefined;
|
|
691
|
+
const negative = receipt.status === 'unavailable' || receipt.status === 'no_match';
|
|
692
|
+
if (['applied', 'changed', 'unavailable', 'no_match'].includes(receipt.status) && !observation)
|
|
693
|
+
return false;
|
|
694
|
+
if (receipt.status === 'applied' && observation?.available && observation.verifiedOfferRef) {
|
|
695
|
+
if (!availabilityReceiptIsComplete(receipt) || observation.gapCodes?.length)
|
|
696
|
+
return false;
|
|
697
|
+
if (!loadedOffersUnchanged(previous, current))
|
|
698
|
+
return false;
|
|
699
|
+
if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef !== checkedOfferVersionRef)
|
|
700
|
+
return false;
|
|
701
|
+
if (current.selectedOfferRef !== checkedOfferRef)
|
|
702
|
+
return false;
|
|
703
|
+
if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs))
|
|
704
|
+
return false;
|
|
705
|
+
if (!current.verifiedOffer || current.verifiedOffer.offerRef !== checkedOfferRef || current.verifiedOffer.offerVersionRef !== checkedOfferVersionRef || current.verifiedOffer.verifiedOfferRef !== observation.verifiedOfferRef || Number.isNaN(Date.parse(current.verifiedOffer.expiresAt)))
|
|
706
|
+
return false;
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
if (receipt.status === 'changed' && observation?.available && observation.currentOfferVersionRef) {
|
|
710
|
+
if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef === checkedOfferVersionRef || observation.verifiedOfferRef || !observation.changedFactRefs.length)
|
|
711
|
+
return false;
|
|
712
|
+
if (!loadedOffersChangedVersionOnly(previous, current, checkedOfferRef, observation.currentOfferVersionRef, observation.changedFactRefs))
|
|
713
|
+
return false;
|
|
714
|
+
if (current.selectedOfferRef !== checkedOfferRef || current.verifiedOffer !== undefined)
|
|
715
|
+
return false;
|
|
716
|
+
return sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs);
|
|
717
|
+
}
|
|
718
|
+
if (!loadedOffersUnchanged(previous, current))
|
|
719
|
+
return false;
|
|
720
|
+
if (negative) {
|
|
721
|
+
if (current.selectedOfferRef !== undefined || current.verifiedOffer !== undefined)
|
|
722
|
+
return false;
|
|
723
|
+
if (!shortlistAfterCheckedOffer(previous.shortlistedOfferRefs, current.shortlistedOfferRefs, checkedOfferRef))
|
|
724
|
+
return false;
|
|
725
|
+
return !current.shortlistedOfferRefs.includes(checkedOfferRef);
|
|
726
|
+
}
|
|
727
|
+
if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs))
|
|
728
|
+
return false;
|
|
729
|
+
if (previous.selectedOfferRef !== current.selectedOfferRef)
|
|
730
|
+
return false;
|
|
731
|
+
if (bookingDigest(previous.verifiedOffer ?? null) !== bookingDigest(current.verifiedOffer ?? null))
|
|
732
|
+
return false;
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
function offerSelectPostActionMatches(previous, current, action, receipt) {
|
|
736
|
+
if (receipt.status !== 'applied')
|
|
737
|
+
return current.selectedOfferRef === previous.selectedOfferRef && loadedOffersUnchanged(previous, current);
|
|
738
|
+
if (receipt.observation.kind !== 'offer.selection' || receipt.observation.offerRef !== action.input.offerRef || receipt.observation.offerVersionRef !== action.input.offerVersionRef)
|
|
739
|
+
return false;
|
|
740
|
+
if (current.selectedOfferRef !== action.input.offerRef)
|
|
741
|
+
return false;
|
|
742
|
+
return loadedOffersUnchanged(previous, current);
|
|
743
|
+
}
|
|
744
|
+
const SEARCH_INVALIDATION_FIELDS = [
|
|
745
|
+
'results', 'visibleHotels', 'focusedHotelRef', 'loadedOffers',
|
|
746
|
+
'shortlistedOfferRefs', 'selectedOfferRef', 'verifiedOffer',
|
|
747
|
+
];
|
|
748
|
+
function searchPostActionMatches(current, action, receipt) {
|
|
749
|
+
if (receipt.revision === action.expectedRevision)
|
|
750
|
+
return true;
|
|
751
|
+
return current.focusedHotelRef === undefined
|
|
752
|
+
&& current.loadedOffers.length === 0
|
|
753
|
+
&& current.shortlistedOfferRefs.length === 0
|
|
754
|
+
&& current.selectedOfferRef === undefined
|
|
755
|
+
&& current.verifiedOffer === undefined;
|
|
756
|
+
}
|
|
757
|
+
function workspaceFieldDigest(value) {
|
|
758
|
+
// Hash an envelope so an absent optional field remains comparable instead
|
|
759
|
+
// of passing undefined to crypto.Hash.update(). Null stays distinguishable.
|
|
760
|
+
return bookingDigest({ value });
|
|
761
|
+
}
|
|
762
|
+
function workspacePostActionMatches(previous, current, action, receipt) {
|
|
763
|
+
if (!workspaceBoundaryMatches(previous, current))
|
|
764
|
+
return false;
|
|
765
|
+
const searchAction = action.kind === 'search.patch' || action.kind === 'search.run';
|
|
766
|
+
const searchMutation = searchAction && receipt.revision > action.expectedRevision;
|
|
767
|
+
if (searchAction && !searchPostActionMatches(current, action, receipt))
|
|
768
|
+
return false;
|
|
769
|
+
const mutable = {
|
|
770
|
+
'search.patch': ['searchDraft', ...SEARCH_INVALIDATION_FIELDS], 'search.run': [...SEARCH_INVALIDATION_FIELDS], 'results.view.patch': ['results'],
|
|
771
|
+
'hotel.focus': ['focusedHotelRef'], 'hotel.select': ['focusedHotelRef'],
|
|
772
|
+
'offers.query': ['loadedOffers'], 'offers.view.patch': ['loadedOffers'],
|
|
773
|
+
'offer.select': ['selectedOfferRef'], 'offer.check': ['selectedOfferRef', 'shortlistedOfferRefs', 'verifiedOffer', 'loadedOffers'],
|
|
774
|
+
};
|
|
775
|
+
if (action.kind === 'offer.select' && !offerSelectPostActionMatches(previous, current, { kind: 'offer.select', input: { offerRef: action.input.offerRef, offerVersionRef: action.input.offerVersionRef } }, receipt))
|
|
776
|
+
return false;
|
|
777
|
+
if (action.kind === 'offer.check' && !offerCheckPostActionMatches(previous, current, { kind: 'offer.check', input: { offerRef: action.input.offerRef, offerVersionRef: action.input.offerVersionRef } }, receipt))
|
|
778
|
+
return false;
|
|
779
|
+
const allowed = new Set(['revision', ...(searchAction && !searchMutation ? [] : (mutable[action.kind] ?? []))]);
|
|
780
|
+
const keys = new Set([...Object.keys(previous), ...Object.keys(current)]);
|
|
781
|
+
for (const key of keys) {
|
|
782
|
+
if (allowed.has(key))
|
|
783
|
+
continue;
|
|
784
|
+
if (workspaceFieldDigest(previous[key]) !== workspaceFieldDigest(current[key]))
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
function approvalOption(taskId, contextRef, sourceTurnId, presentationRequestKey, blocker, to, deliveryNonce) {
|
|
790
|
+
const base = {
|
|
791
|
+
taskId, contextRef, sourceTurnId, presentationRequestKey,
|
|
792
|
+
approvalId: `approval-${blocker.blockerId}-${to}`, deliveryNonce, blockerId: blocker.blockerId,
|
|
793
|
+
sourceActionId: blocker.sourceActionId, sourceReceiptDigest: blocker.sourceReceiptDigest,
|
|
794
|
+
scope: blocker.scope, code: blocker.code, criterionPath: blocker.criterionPath,
|
|
795
|
+
valueDigest: blocker.valueDigest, from: 'must', to, approved: true,
|
|
796
|
+
};
|
|
797
|
+
return { ...base, optionDigest: bookingDigest(base) };
|
|
798
|
+
}
|
|
799
|
+
function approvalOptionDigest(approval) {
|
|
800
|
+
const { optionDigest: _optionDigest, ...base } = approval;
|
|
801
|
+
return bookingDigest(base);
|
|
802
|
+
}
|
|
803
|
+
function presentationKey(taskId, sourceTurnId, actionId, receiptDigest) {
|
|
804
|
+
return `approval:${taskId}:${sourceTurnId}:${actionId}:${receiptDigest}`;
|
|
805
|
+
}
|
|
806
|
+
function assertApprovalRef(ref, state, action) {
|
|
807
|
+
const approval = state.awaitingApproval?.approval;
|
|
808
|
+
const blocker = state.awaitingApproval?.blocker;
|
|
809
|
+
if (!approval || !blocker || ref.approvalId !== approval.approvalId || ref.blockerId !== blocker.blockerId || ref.contextRef !== state.contextRef || ref.sourceTurnId !== state.awaitingApproval?.sourceTurnId || ref.presentationRequestKey !== state.awaitingApproval?.presentationRequestKey || ref.sourceActionId !== blocker.sourceActionId || ref.targetActionId !== action.actionId || ref.sourceRevision !== action.expectedRevision || ref.targetActionKind !== action.kind || ref.to !== approval.to || ref.expiresAt !== state.awaitingApproval?.expiresAt || ref.nonce !== state.awaitingApproval?.nonce || ref.sourceReceiptDigest !== blocker.sourceReceiptDigest || ref.scope !== blocker.scope || ref.code !== blocker.code || ref.criterionPath !== blocker.criterionPath || ref.valueDigest !== blocker.valueDigest)
|
|
810
|
+
throw new Error('ledger_corrupt:approval_ref');
|
|
811
|
+
}
|
|
812
|
+
export class BookingCopilotTaskRuntime {
|
|
813
|
+
ledger;
|
|
814
|
+
idFactory;
|
|
815
|
+
contextRefFactory;
|
|
816
|
+
now;
|
|
817
|
+
approvalTtlMs;
|
|
818
|
+
constructor(ledger, options = {}) {
|
|
819
|
+
this.ledger = ledger;
|
|
820
|
+
this.idFactory = options.idFactory ?? ((prefix) => `${prefix}-${randomUUID()}`);
|
|
821
|
+
this.contextRefFactory = options.contextRefFactory ?? (() => this.idFactory('context'));
|
|
822
|
+
this.now = options.now ?? (() => new Date().toISOString());
|
|
823
|
+
this.approvalTtlMs = options.approvalTtlMs ?? 120_000;
|
|
824
|
+
}
|
|
825
|
+
/** Read-only conflict check used before startTask can append a new turn. */
|
|
826
|
+
assertRequestBinding(requestKey, turn, input) {
|
|
827
|
+
assertRequestKey(requestKey);
|
|
828
|
+
assertPrincipal(input.principal);
|
|
829
|
+
if (input.requestKey !== requestKey)
|
|
830
|
+
throw new Error('request_conflict');
|
|
831
|
+
const existing = this.requestBinding(requestKey);
|
|
832
|
+
if (!existing)
|
|
833
|
+
return;
|
|
834
|
+
const candidate = this.requestBindingPayload(requestKey, turn, input);
|
|
835
|
+
if (existing.requestDigest !== candidate.requestDigest || existing.turnId !== candidate.turnId || existing.workspaceDigest !== candidate.workspaceDigest || existing.workspaceSemanticDigest !== candidate.workspaceSemanticDigest || existing.surface !== candidate.surface || existing.capabilityDigest !== candidate.capabilityDigest || existing.contextRef !== turn.workspace.contextRef || existing.principalDigest !== candidate.principalDigest || existing.scopeDigest !== candidate.scopeDigest || existing.taskHandle !== candidate.taskHandle)
|
|
836
|
+
throw new Error('request_conflict');
|
|
837
|
+
}
|
|
838
|
+
/** Verifies a direct planner turn against its durable TURN row. */
|
|
839
|
+
assertTurnBinding(taskId, turn) {
|
|
840
|
+
assertTaskId(taskId);
|
|
841
|
+
const row = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq DESC`).all(this.ledger.tenant, taskId, TURN);
|
|
842
|
+
const observed = row.map(({ payload }) => JSON.parse(payload)).find((candidate) => candidate.turnId === turn.turnId);
|
|
843
|
+
if (!observed || observed.requestDigest !== bookingTurnDigest(turn) || observed.workspaceDigest !== bookingWorkspaceDigest(turn.workspace) || observed.workspaceSemanticDigest !== bookingWorkspaceSemanticDigest(turn.workspace) || observed.contextRef !== turn.workspace.contextRef)
|
|
844
|
+
throw new Error('turn_conflict');
|
|
845
|
+
}
|
|
846
|
+
/** Rejects a canonical approval after its durable runtime consumption. */
|
|
847
|
+
assertApprovalNotConsumed(taskId, approval) {
|
|
848
|
+
assertTaskId(taskId);
|
|
849
|
+
const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, APPROVAL_CONSUMED);
|
|
850
|
+
for (const row of rows) {
|
|
851
|
+
const consumed = JSON.parse(row.payload);
|
|
852
|
+
if (consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval))
|
|
853
|
+
throw new Error('approval_replayed');
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/** Persists the per-requestKey receipt/replay binding after task creation. */
|
|
857
|
+
persistRequestBinding(requestKey, turn, input) {
|
|
858
|
+
assertRequestKey(requestKey);
|
|
859
|
+
assertPrincipal(input.principal);
|
|
860
|
+
if (input.requestKey !== requestKey)
|
|
861
|
+
throw new Error('request_conflict');
|
|
862
|
+
const run = this.ledger.db.transaction(() => {
|
|
863
|
+
const state = this.requireTask(turn.taskId);
|
|
864
|
+
if (state.phase === 'terminal' || state.phase === 'error')
|
|
865
|
+
throw new Error('task_terminal');
|
|
866
|
+
assertReplayUpgradeReanchored(state);
|
|
867
|
+
this.appendRequestBindingInTransaction(requestKey, turn, input);
|
|
868
|
+
});
|
|
869
|
+
run.immediate();
|
|
870
|
+
}
|
|
871
|
+
/** Binds the untrusted ingress snapshot to server-owned task/context/capabilities. */
|
|
872
|
+
bindIngressTurn(turn, task, turnId) {
|
|
873
|
+
assertSafeRef(turnId);
|
|
874
|
+
const approval = turn.request.approval;
|
|
875
|
+
if (approval && (approval.taskId !== task.taskId || approval.contextRef !== task.contextRef)) {
|
|
876
|
+
throw new Error('invalid_ingress_approval_authority');
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId,
|
|
880
|
+
workspace: {
|
|
881
|
+
...turn.workspace,
|
|
882
|
+
schemaVersion: 'booking.surface', contextRef: task.contextRef, surface: task.surface,
|
|
883
|
+
capabilities: { surface: task.surface, allowedActions: [...task.allowedActions] },
|
|
884
|
+
},
|
|
885
|
+
request: approval
|
|
886
|
+
? { text: turn.request.text, approval: { ...approval } }
|
|
887
|
+
: { text: turn.request.text },
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
startTask(turn, requestBinding) {
|
|
891
|
+
const validation = validateBookingSurface(turn);
|
|
892
|
+
if (!validation.ok)
|
|
893
|
+
throw new Error(`invalid_planner_turn:${errorText(validation)}`);
|
|
894
|
+
if (turn.kind === 'user.turn.ingress')
|
|
895
|
+
throw new Error('ingress_binding_required');
|
|
896
|
+
if (turn.kind === 'action.receipt.continuation')
|
|
897
|
+
throw new Error('receipt_continuation_requires_continue');
|
|
898
|
+
const userTurn = turn;
|
|
899
|
+
if (!userTurn.taskId)
|
|
900
|
+
throw new Error('invalid_planner_turn:task_id_required');
|
|
901
|
+
const taskId = userTurn.taskId;
|
|
902
|
+
assertTaskId(taskId);
|
|
903
|
+
const turnId = userTurn.turnId;
|
|
904
|
+
if (!turnId)
|
|
905
|
+
throw new Error('invalid_planner_turn:turn_id_required');
|
|
906
|
+
assertSafeRef(turnId);
|
|
907
|
+
const contextRef = userTurn.workspace.contextRef;
|
|
908
|
+
const surface = userTurn.workspace.surface;
|
|
909
|
+
const revision = userTurn.workspace.revision;
|
|
910
|
+
const allowedActions = [...userTurn.workspace.capabilities.allowedActions];
|
|
911
|
+
const requestDigest = bookingTurnDigest(turn);
|
|
912
|
+
const boundWorkspace = userTurn.workspace;
|
|
913
|
+
assertWorkspaceLoadedOfferRefsUnique(boundWorkspace);
|
|
914
|
+
const workspaceDigest = bookingWorkspaceDigest(boundWorkspace);
|
|
915
|
+
const workspaceSemanticDigest = bookingWorkspaceSemanticDigest(boundWorkspace);
|
|
916
|
+
const availability = createAvailabilityPolicy(boundWorkspace);
|
|
917
|
+
const run = this.ledger.db.transaction(() => {
|
|
918
|
+
const existing = this.resumeTask(taskId);
|
|
919
|
+
if (existing) {
|
|
920
|
+
if (existing.contextRef !== contextRef)
|
|
921
|
+
throw new Error('task_conflict:context_mismatch');
|
|
922
|
+
if (existing.surface !== surface)
|
|
923
|
+
throw new Error('task_conflict:surface_mismatch');
|
|
924
|
+
if (existing.revision !== revision && existing.phase !== 'waiting_receipt' && !existing.replayUpgradeRequired)
|
|
925
|
+
throw new Error('task_conflict:revision_mismatch');
|
|
926
|
+
if (!sameActions(existing.allowedActions, allowedActions))
|
|
927
|
+
throw new Error('task_conflict:capability_mismatch');
|
|
928
|
+
if (existing.availability.terminal?.code === 'availability_confirmed' && (existing.revision !== revision || existing.workspaceSemanticDigest !== workspaceSemanticDigest))
|
|
929
|
+
throw new Error('workspace_mismatch');
|
|
930
|
+
if (existing.phase === 'terminal' || existing.phase === 'error')
|
|
931
|
+
throw new Error('task_terminal');
|
|
932
|
+
if (turnId && existing.lastTurnId === turnId) {
|
|
933
|
+
this.assertTurnBinding(taskId, userTurn);
|
|
934
|
+
if (requestBinding) {
|
|
935
|
+
assertReplayUpgradeReanchored(existing);
|
|
936
|
+
this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding);
|
|
937
|
+
}
|
|
938
|
+
return existing;
|
|
939
|
+
}
|
|
940
|
+
if (existing.phase === 'waiting_receipt')
|
|
941
|
+
throw new Error('receipt_required');
|
|
942
|
+
if (!existing.replayUpgradeRequired && existing.revision === revision && existing.workspaceSemanticDigest !== workspaceSemanticDigest)
|
|
943
|
+
throw new Error('workspace_mismatch');
|
|
944
|
+
const approval = userTurn && 'approval' in userTurn.request ? userTurn.request.approval : undefined;
|
|
945
|
+
let approvalState;
|
|
946
|
+
if (approval) {
|
|
947
|
+
assertReplayUpgradeReanchored(existing);
|
|
948
|
+
const awaiting = existing.awaitingApproval;
|
|
949
|
+
if (!awaiting)
|
|
950
|
+
throw new Error('approval_not_awaiting');
|
|
951
|
+
if (!awaiting.optionsEmitted)
|
|
952
|
+
throw new Error('approval_not_presented');
|
|
953
|
+
if (Date.parse(awaiting.expiresAt) <= Date.parse(this.now()))
|
|
954
|
+
throw new Error('approval_expired');
|
|
955
|
+
const checked = validateApprovalAgainstBlocker(approval, awaiting.blocker);
|
|
956
|
+
const option = awaiting.options.find((candidate) => candidate.optionDigest === approval.optionDigest && bookingDigest(candidate) === bookingDigest(approval));
|
|
957
|
+
if (!checked.ok || !option || approval.taskId !== existing.taskId || approval.contextRef !== existing.contextRef || approval.sourceTurnId !== awaiting.sourceTurnId || approval.presentationRequestKey !== awaiting.presentationRequestKey || approval.optionDigest !== approvalOptionDigest(approval) || (awaiting.approval && bookingDigest(approval) !== bookingDigest(awaiting.approval)))
|
|
958
|
+
throw new Error('approval_mismatch');
|
|
959
|
+
if (awaiting.approval && bookingDigest(awaiting.approval) === bookingDigest(approval)) {
|
|
960
|
+
// The canonical approval may only be advanced by its original
|
|
961
|
+
// turn identity. A fresh key must not join the in-flight planner
|
|
962
|
+
// and receive the operation batch before durable consumption.
|
|
963
|
+
throw new Error('approval_replayed');
|
|
964
|
+
}
|
|
965
|
+
approvalState = { ...awaiting, approval };
|
|
966
|
+
this.appendApproval(taskId, existing.contextRef, approvalState);
|
|
967
|
+
}
|
|
968
|
+
else if (existing.awaitingApproval) {
|
|
969
|
+
throw new Error('approval_required');
|
|
970
|
+
}
|
|
971
|
+
this.appendTurn(taskId, existing.contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, existing.userTurnCount + 1, turnId, boundWorkspace, Boolean(approval));
|
|
972
|
+
if (requestBinding)
|
|
973
|
+
this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding);
|
|
974
|
+
return this.requireTask(taskId);
|
|
975
|
+
}
|
|
976
|
+
if (userTurn?.request.approval)
|
|
977
|
+
throw new Error('approval_not_awaiting');
|
|
978
|
+
const started = { schema: LEDGER_SCHEMA, taskId, contextRef, surface, revision, allowedActions, workspaceDigest, workspaceSemanticDigest, workspace: boundWorkspace, availability, availabilityDigest: bookingDigest(availability) };
|
|
979
|
+
this.append(STARTED, taskId, started, `booking-copilot:task:${taskId}`);
|
|
980
|
+
this.appendTurn(taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, 1, turnId, boundWorkspace, false);
|
|
981
|
+
if (requestBinding)
|
|
982
|
+
this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding);
|
|
983
|
+
return this.requireTask(taskId);
|
|
984
|
+
});
|
|
985
|
+
return run.immediate();
|
|
986
|
+
}
|
|
987
|
+
resumeTask(taskId) {
|
|
988
|
+
assertTaskId(taskId);
|
|
989
|
+
const rows = this.rows(taskId);
|
|
990
|
+
if (!rows.length)
|
|
991
|
+
return null;
|
|
992
|
+
let state = null;
|
|
993
|
+
let skipLegacyUpgradeTail = false;
|
|
994
|
+
const replayWorkspaceDigests = new Map();
|
|
995
|
+
const consumed = { events: new Map(), actions: new Map(), pendingBatchKeys: new Set(), pendingBatchOpen: false };
|
|
996
|
+
const suppressedDecisionRequestKeys = new Set();
|
|
997
|
+
const suppressedRequestBindingKeys = new Set();
|
|
998
|
+
const suppressedReceiptActionIds = new Set();
|
|
999
|
+
const suppressedApprovalTargetActionIds = new Set();
|
|
1000
|
+
for (const row of rows) {
|
|
1001
|
+
const payload = JSON.parse(row.payload);
|
|
1002
|
+
if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId)
|
|
1003
|
+
throw new Error(`ledger_corrupt:${taskId}:seq=${row.seq}`);
|
|
1004
|
+
if (row.kind === STARTED) {
|
|
1005
|
+
skipLegacyUpgradeTail = false;
|
|
1006
|
+
if (state)
|
|
1007
|
+
throw new Error(`ledger_corrupt:${taskId}:duplicate_start`);
|
|
1008
|
+
const started = payload;
|
|
1009
|
+
let startedWorkspace;
|
|
1010
|
+
try {
|
|
1011
|
+
startedWorkspace = normalizeWorkspaceForReplay(started.workspace);
|
|
1012
|
+
}
|
|
1013
|
+
catch {
|
|
1014
|
+
throw new Error(`ledger_corrupt:${taskId}:start_workspace`);
|
|
1015
|
+
}
|
|
1016
|
+
let initialAvailability;
|
|
1017
|
+
try {
|
|
1018
|
+
initialAvailability = createAvailabilityPolicy(startedWorkspace);
|
|
1019
|
+
}
|
|
1020
|
+
catch {
|
|
1021
|
+
throw new Error(`ledger_corrupt:${taskId}:start_availability`);
|
|
1022
|
+
}
|
|
1023
|
+
try {
|
|
1024
|
+
assertWorkspaceLoadedOfferRefsUnique(startedWorkspace);
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
throw new Error(`ledger_corrupt:${taskId}:start_workspace`);
|
|
1028
|
+
}
|
|
1029
|
+
rememberReplayWorkspaceDigest(replayWorkspaceDigests, started.workspaceDigest, started.workspace, startedWorkspace);
|
|
1030
|
+
const startedAvailability = normalizeAvailabilityPolicyForReplay(started.availability, startedWorkspace, undefined, undefined, replayWorkspaceDigests);
|
|
1031
|
+
if (!started.workspaceDigest || !started.workspaceSemanticDigest || !started.workspace || !started.availability || !started.availabilityDigest || !validateAvailabilityPolicy(startedAvailability) || !replayDigestMatches(started.availabilityDigest, started.availability, startedAvailability) || bookingDigest(initialAvailability) !== bookingDigest(startedAvailability) || !replayWorkspaceDigestMatches(started.workspaceDigest, started.workspace, startedWorkspace) || !replayWorkspaceSemanticDigestMatches(started.workspaceSemanticDigest, started.workspace, startedWorkspace))
|
|
1032
|
+
throw new Error(`ledger_corrupt:${taskId}:start_workspace`);
|
|
1033
|
+
state = { schemaVersion: 'booking.surface', taskId, contextRef: started.contextRef, surface: started.surface, revision: started.revision, allowedActions: [...started.allowedActions], userTurnCount: 0, operationCount: 0, phase: availabilityTerminalEndsTask(startedAvailability.terminal) ? 'terminal' : 'planning', lastSequence: 0, workspaceDigest: bookingWorkspaceDigest(startedWorkspace), workspaceSemanticDigest: bookingWorkspaceSemanticDigest(startedWorkspace), workspaceSnapshot: startedWorkspace, availability: startedAvailability };
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
if (!state)
|
|
1037
|
+
throw new Error(`ledger_corrupt:${taskId}:event_before_start`);
|
|
1038
|
+
if (payload.contextRef !== state.contextRef)
|
|
1039
|
+
throw new Error(`ledger_corrupt:${taskId}:context_drift`);
|
|
1040
|
+
if (row.kind === REQUEST_BINDING) {
|
|
1041
|
+
if (skipLegacyUpgradeTail) {
|
|
1042
|
+
const binding = payload;
|
|
1043
|
+
if (typeof binding.requestKey === 'string')
|
|
1044
|
+
suppressedRequestBindingKeys.add(binding.requestKey);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const binding = payload;
|
|
1048
|
+
const turnRows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, TURN);
|
|
1049
|
+
const rawBoundTurn = turnRows.map(({ payload }) => JSON.parse(payload)).find((candidate) => candidate.turnId === binding.turnId);
|
|
1050
|
+
let boundTurn;
|
|
1051
|
+
try {
|
|
1052
|
+
boundTurn = rawBoundTurn ? { ...rawBoundTurn, workspace: normalizeWorkspaceForReplay(rawBoundTurn.workspace) } : undefined;
|
|
1053
|
+
}
|
|
1054
|
+
catch {
|
|
1055
|
+
throw new Error(`ledger_corrupt:${taskId}:request_binding`);
|
|
1056
|
+
}
|
|
1057
|
+
if (!isSafeBookingRequestKey(binding.requestKey) || !safeIdentity(binding.turnId) || !safeIdentity(binding.contextRef) || (binding.taskHandle !== undefined && !safeIdentity(binding.taskHandle)) || !/^[0-9a-f]{64}$/.test(binding.requestDigest) || !/^[0-9a-f]{64}$/.test(binding.workspaceDigest) || !/^[0-9a-f]{64}$/.test(binding.workspaceSemanticDigest) || !/^[0-9a-f]{64}$/.test(binding.capabilityDigest) || !/^[0-9a-f]{64}$/.test(binding.principalDigest) || !/^[0-9a-f]{64}$/.test(binding.scopeDigest) || !['tenant', 'customer_portal', 'storefront', 'payment_link'].includes(binding.surface) || !boundTurn || boundTurn.requestDigest !== binding.requestDigest || boundTurn.workspaceDigest !== binding.workspaceDigest || boundTurn.workspaceSemanticDigest !== binding.workspaceSemanticDigest || bookingDigest(boundTurn.workspace.capabilities) !== binding.capabilityDigest || boundTurn.workspace.surface !== binding.surface)
|
|
1058
|
+
throw new Error(`ledger_corrupt:${taskId}:request_binding`);
|
|
1059
|
+
}
|
|
1060
|
+
else if (row.kind === TURN) {
|
|
1061
|
+
skipLegacyUpgradeTail = false;
|
|
1062
|
+
const t = payload;
|
|
1063
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'approval'))
|
|
1064
|
+
throw new Error(`ledger_corrupt:${taskId}:turn_approval`);
|
|
1065
|
+
let turnWorkspace;
|
|
1066
|
+
try {
|
|
1067
|
+
turnWorkspace = normalizeWorkspaceForReplay(t.workspace);
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
throw new Error(`ledger_corrupt:${taskId}:turn_workspace`);
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
assertWorkspaceLoadedOfferRefsUnique(turnWorkspace);
|
|
1074
|
+
}
|
|
1075
|
+
catch {
|
|
1076
|
+
throw new Error(`ledger_corrupt:${taskId}:turn_workspace`);
|
|
1077
|
+
}
|
|
1078
|
+
if (!t.workspaceDigest || !t.workspaceSemanticDigest || !t.workspace || !replayWorkspaceDigestMatches(t.workspaceDigest, t.workspace, turnWorkspace) || !replayWorkspaceSemanticDigestMatches(t.workspaceSemanticDigest, t.workspace, turnWorkspace))
|
|
1079
|
+
throw new Error(`ledger_corrupt:${taskId}:turn_workspace`);
|
|
1080
|
+
// Same-revision replay guard: an ordinary TURN row must not silently
|
|
1081
|
+
// rewrite the durable workspace semantic digest. A ledger written by
|
|
1082
|
+
// an old public startTask (no live-time same-revision guard) would
|
|
1083
|
+
// otherwise smuggle a verifiedOffer/loadedOffer/selection drift into
|
|
1084
|
+
// replay and turn the replayed workspace into checkout authority.
|
|
1085
|
+
// The explicit replayUpgradeRequired reanchor stays intact for the
|
|
1086
|
+
// legacy-tail replan path.
|
|
1087
|
+
if (state.availability.terminal?.code === 'availability_confirmed' && (state.revision !== turnWorkspace.revision || state.workspaceSemanticDigest !== bookingWorkspaceSemanticDigest(turnWorkspace)))
|
|
1088
|
+
throw new Error(`ledger_corrupt:${taskId}:confirmed_workspace_drift`);
|
|
1089
|
+
if (!state.replayUpgradeRequired && state.revision === turnWorkspace.revision && state.workspaceSemanticDigest !== bookingWorkspaceSemanticDigest(turnWorkspace))
|
|
1090
|
+
throw new Error(`ledger_corrupt:${taskId}:same_revision_turn_workspace_drift`);
|
|
1091
|
+
rememberReplayWorkspaceDigest(replayWorkspaceDigests, t.workspaceDigest, t.workspace, turnWorkspace);
|
|
1092
|
+
state.userTurnCount++;
|
|
1093
|
+
state.lastTurnId = t.turnId;
|
|
1094
|
+
if (!/^[0-9a-f]{64}$/.test(t.requestDigest))
|
|
1095
|
+
throw new Error(`ledger_corrupt:${taskId}:turn_request_digest`);
|
|
1096
|
+
state.lastTurnRequestDigest = t.requestDigest;
|
|
1097
|
+
if (!t.preserveIntent) {
|
|
1098
|
+
delete state.activeIntent;
|
|
1099
|
+
delete state.lastCompletedAction;
|
|
1100
|
+
delete state.lastReceipt;
|
|
1101
|
+
}
|
|
1102
|
+
state.workspaceDigest = bookingWorkspaceDigest(turnWorkspace);
|
|
1103
|
+
state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(turnWorkspace);
|
|
1104
|
+
state.workspaceSnapshot = turnWorkspace;
|
|
1105
|
+
state.revision = turnWorkspace.revision;
|
|
1106
|
+
delete state.replayUpgradeRequired;
|
|
1107
|
+
// Approval authority is established only by APPROVAL_GRANTED; a turn
|
|
1108
|
+
// is merely an observation and cannot restore or mutate approval state.
|
|
1109
|
+
}
|
|
1110
|
+
else if (row.kind === APPROVAL_GRANTED) {
|
|
1111
|
+
if (skipLegacyUpgradeTail)
|
|
1112
|
+
continue;
|
|
1113
|
+
const granted = payload;
|
|
1114
|
+
if (!granted.approval || granted.approvalDigest !== bookingDigest(granted.approval) || !granted.approval.sourceTurnId || !granted.approval.presentationRequestKey || !validateCriterionBlocker(granted.approval.blocker).ok || !granted.approval.options?.length || granted.approval.options.some((option) => !validateApprovalAgainstBlocker(option, granted.approval.blocker).ok || option.taskId !== taskId || option.contextRef !== state.contextRef || option.sourceTurnId !== granted.approval.sourceTurnId || option.presentationRequestKey !== granted.approval.presentationRequestKey || option.optionDigest !== approvalOptionDigest(option)) || (granted.approval.approval && !granted.approval.options.some((option) => bookingDigest(option) === bookingDigest(granted.approval.approval))))
|
|
1115
|
+
throw new Error(`ledger_corrupt:${taskId}:approval_granted`);
|
|
1116
|
+
state.awaitingApproval = granted.approval;
|
|
1117
|
+
state.phase = 'input_required';
|
|
1118
|
+
}
|
|
1119
|
+
else if (row.kind === APPROVAL_OFFERED) {
|
|
1120
|
+
if (skipLegacyUpgradeTail)
|
|
1121
|
+
continue;
|
|
1122
|
+
const offered = payload;
|
|
1123
|
+
if (!offered.approval || offered.approvalDigest !== bookingDigest(offered.approval) || !state.awaitingApproval || bookingDigest(offered.approval) !== bookingDigest(state.awaitingApproval))
|
|
1124
|
+
throw new Error(`ledger_corrupt:${taskId}:approval_offered`);
|
|
1125
|
+
// This is a durable outbox intent only. It is deliberately not
|
|
1126
|
+
// evidence that the question entered a replayable output batch:
|
|
1127
|
+
// a crash between this row and DECISION_BATCH must still reject an
|
|
1128
|
+
// approval until the exact question can be replayed.
|
|
1129
|
+
state.phase = 'input_required';
|
|
1130
|
+
}
|
|
1131
|
+
else if (row.kind === ACTION) {
|
|
1132
|
+
const actionPayload = payload;
|
|
1133
|
+
if (skipLegacyUpgradeTail) {
|
|
1134
|
+
try {
|
|
1135
|
+
consumeSkippedActionOrdinal(state, actionPayload, consumed);
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
throw new Error(`ledger_corrupt:${taskId}:action`);
|
|
1139
|
+
}
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
const rawAction = actionPayload.action;
|
|
1143
|
+
const operationCount = actionPayload.operationCount ?? state.operationCount + 1;
|
|
1144
|
+
const { actionDigest: _rawActionDigest, ...rawActionBase } = rawAction;
|
|
1145
|
+
if (rawAction.actionDigest !== checkpointDigest(rawActionBase) || rawAction.contextRef !== state.contextRef || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !Number.isSafeInteger(rawAction.sequence) || rawAction.sequence <= state.lastSequence)
|
|
1146
|
+
throw new Error(`ledger_corrupt:${taskId}:action`);
|
|
1147
|
+
const hasIntent = Object.prototype.hasOwnProperty.call(actionPayload, 'intent');
|
|
1148
|
+
if (!hasIntent) {
|
|
1149
|
+
// Pre-intent rows remain readable without being upgraded into a
|
|
1150
|
+
// guessed user goal. Stop before applying newer action invariants,
|
|
1151
|
+
// preserve the ordinal, and suppress the legacy tail until a fresh
|
|
1152
|
+
// user turn reanchors the workspace and intent.
|
|
1153
|
+
state = replanAfterReplayUpgradeGap(state);
|
|
1154
|
+
try {
|
|
1155
|
+
consumeSkippedActionOrdinal(state, actionPayload, consumed);
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
throw new Error(`ledger_corrupt:${taskId}:action`);
|
|
1159
|
+
}
|
|
1160
|
+
skipLegacyUpgradeTail = true;
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
let a;
|
|
1164
|
+
try {
|
|
1165
|
+
a = normalizeActionCheckpointForReplay(rawAction, state.workspaceSnapshot);
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
if (error instanceof Error && error.message.startsWith('booking_replan_required:')) {
|
|
1169
|
+
state = replanAfterReplayUpgradeGap(state);
|
|
1170
|
+
try {
|
|
1171
|
+
consumeSkippedActionOrdinal(state, actionPayload, consumed);
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
throw new Error(`ledger_corrupt:${taskId}:action`);
|
|
1175
|
+
}
|
|
1176
|
+
skipLegacyUpgradeTail = true;
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
throw error;
|
|
1180
|
+
}
|
|
1181
|
+
const { actionDigest: _actionDigest, ...actionBase } = a;
|
|
1182
|
+
if (!actionPayload.availability || actionPayload.availabilityDigest !== bookingDigest(actionPayload.availability))
|
|
1183
|
+
throw new Error(`ledger_corrupt:${taskId}:action`);
|
|
1184
|
+
let actionAvailability;
|
|
1185
|
+
try {
|
|
1186
|
+
actionAvailability = normalizeAvailabilityPolicyForReplay(actionPayload.availability, state.workspaceSnapshot, a, state.availability, replayWorkspaceDigests);
|
|
1187
|
+
}
|
|
1188
|
+
catch (error) {
|
|
1189
|
+
if (error instanceof Error && error.message.startsWith('booking_replan_required:')) {
|
|
1190
|
+
state = replanAfterReplayUpgradeGap(state);
|
|
1191
|
+
try {
|
|
1192
|
+
consumeSkippedActionOrdinal(state, actionPayload, consumed);
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
throw new Error(`ledger_corrupt:${taskId}:action`);
|
|
1196
|
+
}
|
|
1197
|
+
skipLegacyUpgradeTail = true;
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
throw error;
|
|
1201
|
+
}
|
|
1202
|
+
if (!checkpointDigestMatchesForReplay(rawAction, a) || a.actionDigest !== checkpointDigest(actionBase) || a.sourceTurnId !== state.lastTurnId || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !validateAvailabilityPolicy(actionAvailability) || !replayDigestMatches(actionPayload.availabilityDigest, actionPayload.availability, actionAvailability) || !state.workspaceSnapshot)
|
|
1203
|
+
throw new Error(`ledger_corrupt:${taskId}:action`);
|
|
1204
|
+
if (actionPayload.intent) {
|
|
1205
|
+
try {
|
|
1206
|
+
validateIntentCheckpoint(actionPayload.intent, state, a);
|
|
1207
|
+
}
|
|
1208
|
+
catch {
|
|
1209
|
+
throw new Error(`ledger_corrupt:${taskId}:intent`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
else
|
|
1213
|
+
throw new Error(`ledger_corrupt:${taskId}:intent`);
|
|
1214
|
+
if (a.relaxationApprovalRef)
|
|
1215
|
+
assertApprovalRef(a.relaxationApprovalRef, state, a);
|
|
1216
|
+
if (state.pendingAction)
|
|
1217
|
+
throw new Error(`ledger_corrupt:${taskId}:parallel_actions`);
|
|
1218
|
+
let expectedAvailability;
|
|
1219
|
+
try {
|
|
1220
|
+
expectedAvailability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, a);
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
throw new Error(`ledger_corrupt:${taskId}:action_transition`);
|
|
1224
|
+
}
|
|
1225
|
+
if (bookingDigest(expectedAvailability) !== bookingDigest(actionAvailability))
|
|
1226
|
+
throw new Error(`ledger_corrupt:${taskId}:action_transition`);
|
|
1227
|
+
state.operationCount = operationCount;
|
|
1228
|
+
state.availability = actionAvailability;
|
|
1229
|
+
if (actionPayload.intent)
|
|
1230
|
+
state.activeIntent = actionPayload.intent;
|
|
1231
|
+
state.pendingAction = a;
|
|
1232
|
+
state.phase = 'waiting_receipt';
|
|
1233
|
+
state.lastSequence = Math.max(state.lastSequence, a.sequence);
|
|
1234
|
+
{
|
|
1235
|
+
rememberConsumedActionIdentity(consumed, a);
|
|
1236
|
+
}
|
|
1237
|
+
delete state.awaitingApproval;
|
|
1238
|
+
}
|
|
1239
|
+
else if (row.kind === RECEIPT) {
|
|
1240
|
+
if (skipLegacyUpgradeTail) {
|
|
1241
|
+
const r = payload;
|
|
1242
|
+
if (typeof r.receipt?.actionId === 'string')
|
|
1243
|
+
suppressedReceiptActionIds.add(r.receipt.actionId);
|
|
1244
|
+
// A standalone legacy action has no decision envelope to consume
|
|
1245
|
+
// its pending identity set. Its receipt closes that skipped unit;
|
|
1246
|
+
// otherwise a later, freshly anchored batch is falsely treated as
|
|
1247
|
+
// a graft containing identities from the legacy action.
|
|
1248
|
+
clearPendingBatch(consumed);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const r = payload;
|
|
1252
|
+
const operationCount = r.operationCount ?? state.operationCount;
|
|
1253
|
+
if (!state.pendingAction || state.pendingAction.actionId !== r.receipt.actionId)
|
|
1254
|
+
throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`);
|
|
1255
|
+
let receiptWorkspace;
|
|
1256
|
+
try {
|
|
1257
|
+
receiptWorkspace = normalizeWorkspaceForReplay(r.workspace);
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`);
|
|
1261
|
+
}
|
|
1262
|
+
try {
|
|
1263
|
+
assertWorkspaceLoadedOfferRefsUnique(receiptWorkspace);
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`);
|
|
1267
|
+
}
|
|
1268
|
+
if (!r.workspaceDigest || !r.workspaceSemanticDigest || !r.workspace || !replayWorkspaceDigestMatches(r.workspaceDigest, r.workspace, receiptWorkspace) || !replayWorkspaceSemanticDigestMatches(r.workspaceSemanticDigest, r.workspace, receiptWorkspace))
|
|
1269
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`);
|
|
1270
|
+
rememberReplayWorkspaceDigest(replayWorkspaceDigests, r.workspaceDigest, r.workspace, receiptWorkspace);
|
|
1271
|
+
if (!replayDigestMatches(r.receiptDigest, r.receipt, r.receipt) || r.receipt.contextRef !== state.contextRef || r.receipt.actionId !== state.pendingAction.actionId || !Number.isSafeInteger(r.receipt.revision) || r.receipt.revision < state.pendingAction.expectedRevision || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount)
|
|
1272
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt`);
|
|
1273
|
+
if (!r.availability || r.availabilityDigest !== bookingDigest(r.availability))
|
|
1274
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt`);
|
|
1275
|
+
let receipt;
|
|
1276
|
+
try {
|
|
1277
|
+
receipt = normalizeReceiptForReplay(r.receipt, state.pendingAction, receiptWorkspace);
|
|
1278
|
+
}
|
|
1279
|
+
catch (error) {
|
|
1280
|
+
if (error instanceof Error && error.message.startsWith('booking_replan_required:')) {
|
|
1281
|
+
clearPendingBatch(consumed);
|
|
1282
|
+
state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision);
|
|
1283
|
+
skipLegacyUpgradeTail = true;
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
throw error;
|
|
1287
|
+
}
|
|
1288
|
+
let receiptAvailability;
|
|
1289
|
+
try {
|
|
1290
|
+
receiptAvailability = normalizeAvailabilityPolicyForReplay(r.availability, receiptWorkspace, state.pendingAction, state.availability, replayWorkspaceDigests, receipt);
|
|
1291
|
+
}
|
|
1292
|
+
catch (error) {
|
|
1293
|
+
if (error instanceof Error && error.message.startsWith('booking_replan_required:')) {
|
|
1294
|
+
clearPendingBatch(consumed);
|
|
1295
|
+
state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision);
|
|
1296
|
+
skipLegacyUpgradeTail = true;
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
throw error;
|
|
1300
|
+
}
|
|
1301
|
+
if (!replayDigestMatches(r.receiptDigest, r.receipt, receipt) || !validateBookingSurface(receipt).ok || !r.availability || !r.availabilityDigest || !validateAvailabilityPolicy(receiptAvailability) || !replayDigestMatches(r.availabilityDigest, r.availability, receiptAvailability))
|
|
1302
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt`);
|
|
1303
|
+
if (Boolean(r.availabilityTerminal) !== Boolean(receiptAvailability.terminal) || (r.availabilityTerminal && receiptAvailability.terminal && bookingDigest(r.availabilityTerminal) !== bookingDigest(receiptAvailability.terminal)))
|
|
1304
|
+
throw new Error(`ledger_corrupt:${taskId}:availability_terminal`);
|
|
1305
|
+
assertCanonicalReceipt(receipt);
|
|
1306
|
+
try {
|
|
1307
|
+
assertOffersReceiptWorkspaceBinding(receipt, receiptWorkspace, state.pendingAction);
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt_transition`);
|
|
1311
|
+
}
|
|
1312
|
+
if (!state.workspaceSnapshot || !workspacePostActionMatches(state.workspaceSnapshot, receiptWorkspace, state.pendingAction, receipt) || !receiptObservationMatchesAction(receipt.observation.kind, state.pendingAction.kind) || !receiptTargetMatchesAction(receipt, state.pendingAction))
|
|
1313
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt_transition`);
|
|
1314
|
+
let expectedAvailability;
|
|
1315
|
+
try {
|
|
1316
|
+
expectedAvailability = reduceAvailabilityReceipt(state.availability, receiptWorkspace, receipt, state.pendingAction);
|
|
1317
|
+
}
|
|
1318
|
+
catch {
|
|
1319
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt_transition`);
|
|
1320
|
+
}
|
|
1321
|
+
if (bookingDigest(expectedAvailability) !== bookingDigest(receiptAvailability))
|
|
1322
|
+
throw new Error(`ledger_corrupt:${taskId}:receipt_transition`);
|
|
1323
|
+
state.lastCompletedAction = state.pendingAction;
|
|
1324
|
+
state.lastReceipt = receipt;
|
|
1325
|
+
state.revision = receipt.revision;
|
|
1326
|
+
state.workspaceDigest = bookingWorkspaceDigest(receiptWorkspace);
|
|
1327
|
+
state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(receiptWorkspace);
|
|
1328
|
+
state.workspaceSnapshot = receiptWorkspace;
|
|
1329
|
+
state.availability = receiptAvailability;
|
|
1330
|
+
delete state.pendingAction;
|
|
1331
|
+
state.phase = availabilityTerminalEndsTask(receiptAvailability.terminal) || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS ? 'terminal' : 'planning';
|
|
1332
|
+
clearPendingBatch(consumed);
|
|
1333
|
+
delete state.awaitingApproval;
|
|
1334
|
+
}
|
|
1335
|
+
else if (row.kind === APPROVAL_CONSUMED) {
|
|
1336
|
+
if (skipLegacyUpgradeTail) {
|
|
1337
|
+
const consumed = payload;
|
|
1338
|
+
if (typeof consumed.ref?.targetActionId === 'string')
|
|
1339
|
+
suppressedApprovalTargetActionIds.add(consumed.ref.targetActionId);
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
const consumed = payload;
|
|
1343
|
+
if (!consumed.ref || consumed.approvalDigest !== bookingDigest(consumed.ref) || !consumed.approval?.approval || !consumed.approval.options?.some((option) => bookingDigest(option) === bookingDigest(consumed.approval.approval)) || !state.awaitingApproval || bookingDigest(consumed.approval) !== bookingDigest(state.awaitingApproval) || consumed.ref.approvalId !== consumed.approval.approval.approvalId || consumed.ref.blockerId !== consumed.approval.blocker.blockerId || consumed.ref.contextRef !== state.contextRef || consumed.ref.sourceTurnId !== consumed.approval.sourceTurnId || consumed.ref.presentationRequestKey !== consumed.approval.presentationRequestKey || consumed.ref.sourceActionId !== consumed.approval.blocker.sourceActionId || consumed.ref.targetActionId === '' || consumed.ref.sourceReceiptDigest !== consumed.approval.blocker.sourceReceiptDigest || consumed.ref.nonce !== consumed.approval.nonce || consumed.ref.nonce !== consumed.approval.approval.deliveryNonce || consumed.ref.to !== consumed.approval.approval.to || consumed.ref.scope !== consumed.approval.blocker.scope || consumed.ref.code !== consumed.approval.blocker.code || consumed.ref.criterionPath !== consumed.approval.blocker.criterionPath || consumed.ref.valueDigest !== consumed.approval.blocker.valueDigest)
|
|
1344
|
+
throw new Error(`ledger_corrupt:${taskId}:approval`);
|
|
1345
|
+
}
|
|
1346
|
+
else if (row.kind === DECISION_BATCH) {
|
|
1347
|
+
const batch = payload;
|
|
1348
|
+
if (skipLegacyUpgradeTail) {
|
|
1349
|
+
try {
|
|
1350
|
+
consumeSkippedEventSequence(state, batch, consumed);
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
|
|
1354
|
+
}
|
|
1355
|
+
if (typeof batch.requestKey === 'string')
|
|
1356
|
+
suppressedDecisionRequestKeys.add(batch.requestKey);
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
if (!batch.requestKey || !Array.isArray(batch.events) || batch.events.some((event) => event.taskId !== taskId))
|
|
1360
|
+
throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
|
|
1361
|
+
let batchEvents;
|
|
1362
|
+
try {
|
|
1363
|
+
batchEvents = normalizeDecisionBatchEnvelopeForReplay(batch, consumed, true);
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
|
|
1367
|
+
}
|
|
1368
|
+
for (const event of batchEvents) {
|
|
1369
|
+
if (event.kind !== 'question')
|
|
1370
|
+
continue;
|
|
1371
|
+
const awaiting = state.awaitingApproval;
|
|
1372
|
+
if (!awaiting || bookingDigest(event.question.blocker) !== bookingDigest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => bookingDigest(option) === bookingDigest(approval))))
|
|
1373
|
+
throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`);
|
|
1374
|
+
// Only the atomic, durable batch makes the presentation nonce
|
|
1375
|
+
// usable. APPROVAL_OFFERED alone is intentionally insufficient.
|
|
1376
|
+
awaiting.optionsEmitted = true;
|
|
1377
|
+
state.phase = 'input_required';
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
else if (row.kind === EVENT) {
|
|
1381
|
+
const event = payload;
|
|
1382
|
+
if (skipLegacyUpgradeTail) {
|
|
1383
|
+
try {
|
|
1384
|
+
consumeSkippedEventSequence(state, event, consumed);
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
throw new Error(`ledger_corrupt:${taskId}:event`);
|
|
1388
|
+
}
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
state.lastSequence = Math.max(state.lastSequence, event.sequence);
|
|
1392
|
+
{
|
|
1393
|
+
rememberConsumedEventIdentity(consumed, event);
|
|
1394
|
+
}
|
|
1395
|
+
if (event.eventKind === 'status')
|
|
1396
|
+
state.phase = event.status === 'submitted' ? 'submitted' : event.status === 'working' ? 'working' : event.status === 'waiting_receipt' ? 'waiting_receipt' : 'input_required';
|
|
1397
|
+
else if (event.eventKind === 'question')
|
|
1398
|
+
state.phase = 'input_required';
|
|
1399
|
+
else if (event.eventKind === 'terminal')
|
|
1400
|
+
state.phase = 'terminal';
|
|
1401
|
+
else if (event.eventKind === 'error')
|
|
1402
|
+
state.phase = 'error';
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
if (state) {
|
|
1407
|
+
if (suppressedDecisionRequestKeys.size)
|
|
1408
|
+
state.legacySuppressedDecisionRequestKeys = [...suppressedDecisionRequestKeys];
|
|
1409
|
+
if (suppressedRequestBindingKeys.size)
|
|
1410
|
+
state.legacySuppressedRequestBindingKeys = [...suppressedRequestBindingKeys];
|
|
1411
|
+
if (suppressedReceiptActionIds.size)
|
|
1412
|
+
state.legacySuppressedReceiptActionIds = [...suppressedReceiptActionIds];
|
|
1413
|
+
if (suppressedApprovalTargetActionIds.size)
|
|
1414
|
+
state.legacySuppressedApprovalTargetActionIds = [...suppressedApprovalTargetActionIds];
|
|
1415
|
+
}
|
|
1416
|
+
return state;
|
|
1417
|
+
}
|
|
1418
|
+
withReceiptDigest(receipt) {
|
|
1419
|
+
// The source receipt digest is the authority used by the approval binding.
|
|
1420
|
+
const sourceDigest = receiptSourceDigest(receipt);
|
|
1421
|
+
return { ...receipt, resultContract: { ...receipt.resultContract, blockers: receipt.resultContract.blockers.map((b) => ({ ...b, sourceReceiptDigest: sourceDigest })) } };
|
|
1422
|
+
}
|
|
1423
|
+
receiptDigest(receipt) { return canonicalReceiptDigest(receipt); }
|
|
1424
|
+
/** Builds the only relaxation question exposed to the planner stream. The
|
|
1425
|
+
* blocker is ledger-derived; a planner cannot invent its tuple or ref. */
|
|
1426
|
+
approvalQuestion(taskId) {
|
|
1427
|
+
const task = this.requireTask(taskId);
|
|
1428
|
+
const awaiting = task.awaitingApproval;
|
|
1429
|
+
if (!awaiting)
|
|
1430
|
+
throw new Error('approval_not_awaiting');
|
|
1431
|
+
return {
|
|
1432
|
+
kind: 'question',
|
|
1433
|
+
question: {
|
|
1434
|
+
questionId: `question-${awaiting.blocker.blockerId}`,
|
|
1435
|
+
prompt: `Relax required criterion ${awaiting.blocker.criterionPath}?`,
|
|
1436
|
+
missingFields: [awaiting.blocker.criterionPath],
|
|
1437
|
+
type: 'relaxation_approval_required',
|
|
1438
|
+
blocker: awaiting.blocker,
|
|
1439
|
+
approvalOptions: awaiting.options.map((approval) => ({ approval })),
|
|
1440
|
+
},
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
approvalPresentationRequestKey(taskId) {
|
|
1444
|
+
const task = this.requireTask(taskId);
|
|
1445
|
+
const awaiting = task.awaitingApproval;
|
|
1446
|
+
if (!awaiting)
|
|
1447
|
+
throw new Error('approval_not_awaiting');
|
|
1448
|
+
return awaiting.presentationRequestKey;
|
|
1449
|
+
}
|
|
1450
|
+
commitDecisionBatch(taskId, requestKey, decisions) {
|
|
1451
|
+
return this.applyDecisionBatch(taskId, requestKey, decisions);
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Persists the server's initial SSE progress before planner execution. The
|
|
1455
|
+
* progress batch has a derived idempotency key, so the final decision batch
|
|
1456
|
+
* keeps the public request key and never repeats these status events.
|
|
1457
|
+
*/
|
|
1458
|
+
ensureProgressBatch(taskId, requestKey, includeSubmitted = false) {
|
|
1459
|
+
assertInternalDecisionKey(requestKey);
|
|
1460
|
+
const progressKey = progressDecisionKey(requestKey);
|
|
1461
|
+
const prior = this.readDecisionBatch(taskId, progressKey);
|
|
1462
|
+
if (prior)
|
|
1463
|
+
return prior;
|
|
1464
|
+
if (this.readDecisionBatch(taskId, requestKey))
|
|
1465
|
+
return [];
|
|
1466
|
+
const run = this.ledger.db.transaction(() => {
|
|
1467
|
+
const state = this.requireTask(taskId);
|
|
1468
|
+
if (state.phase === 'terminal' || state.phase === 'error')
|
|
1469
|
+
return [];
|
|
1470
|
+
const recorded = this.readDecisionBatch(taskId, progressKey);
|
|
1471
|
+
if (recorded)
|
|
1472
|
+
return recorded;
|
|
1473
|
+
const events = [];
|
|
1474
|
+
if (includeSubmitted)
|
|
1475
|
+
events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'submitted' }));
|
|
1476
|
+
events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'working' }));
|
|
1477
|
+
this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey: progressKey, events }, `booking-copilot:decision-batch:${taskId}:${progressKey}`);
|
|
1478
|
+
return events;
|
|
1479
|
+
});
|
|
1480
|
+
return run.immediate();
|
|
1481
|
+
}
|
|
1482
|
+
readProgressBatch(taskId, requestKey) {
|
|
1483
|
+
assertInternalDecisionKey(requestKey);
|
|
1484
|
+
return this.readDecisionBatch(taskId, progressDecisionKey(requestKey));
|
|
1485
|
+
}
|
|
1486
|
+
readDecisionBatch(taskId, requestKey) {
|
|
1487
|
+
const state = this.requireTask(taskId);
|
|
1488
|
+
if (state.legacySuppressedDecisionRequestKeys?.includes(requestKey))
|
|
1489
|
+
throw new Error('request_key_invalidated');
|
|
1490
|
+
const rows = this.ledger.db.prepare('SELECT seq, payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq DESC').all(this.ledger.tenant, taskId, DECISION_BATCH);
|
|
1491
|
+
for (const row of rows) {
|
|
1492
|
+
const payload = JSON.parse(row.payload);
|
|
1493
|
+
if (payload.requestKey !== requestKey)
|
|
1494
|
+
continue;
|
|
1495
|
+
if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId || payload.contextRef !== state.contextRef || !Array.isArray(payload.events) || payload.events.some((event) => event.taskId !== taskId || event.contextRef !== state.contextRef))
|
|
1496
|
+
throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
|
|
1497
|
+
const events = this.normalizeDecisionBatchFromDurableRows(taskId, payload.events, row.seq);
|
|
1498
|
+
if (!events)
|
|
1499
|
+
throw new Error(`ledger_corrupt:${taskId}:decision_batch`);
|
|
1500
|
+
for (const event of events) {
|
|
1501
|
+
if (event.kind !== 'question')
|
|
1502
|
+
continue;
|
|
1503
|
+
const awaiting = state.awaitingApproval;
|
|
1504
|
+
if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingDigest(event.question.blocker) !== bookingDigest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingDigest(option) === bookingDigest(approval))))
|
|
1505
|
+
throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`);
|
|
1506
|
+
}
|
|
1507
|
+
return events;
|
|
1508
|
+
}
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
/** Materializes the durable policy/budget terminal response once, before SSE. */
|
|
1512
|
+
terminalDecisionBatch(taskId, requestKey) {
|
|
1513
|
+
assertInternalDecisionKey(requestKey);
|
|
1514
|
+
const prior = this.readDecisionBatch(taskId, requestKey);
|
|
1515
|
+
if (prior)
|
|
1516
|
+
return prior;
|
|
1517
|
+
const state = this.requireTask(taskId);
|
|
1518
|
+
if (state.phase === 'terminal' || state.phase === 'error')
|
|
1519
|
+
throw new Error('task_terminal');
|
|
1520
|
+
assertReplayUpgradeReanchored(state);
|
|
1521
|
+
const run = this.ledger.db.transaction(() => {
|
|
1522
|
+
const state = this.requireTask(taskId);
|
|
1523
|
+
if (!taskHasTerminalBudget(state) || state.phase !== 'terminal')
|
|
1524
|
+
throw new Error('task_not_terminal');
|
|
1525
|
+
assertReplayUpgradeReanchored(state);
|
|
1526
|
+
const event = this.terminalEvent(state);
|
|
1527
|
+
this.appendTerminalEvent(taskId, event);
|
|
1528
|
+
this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events: [event] }, `booking-copilot:decision-batch:${taskId}:${requestKey}`);
|
|
1529
|
+
return [event];
|
|
1530
|
+
});
|
|
1531
|
+
return run.immediate();
|
|
1532
|
+
}
|
|
1533
|
+
/** Atomically applies planner decisions and records the exact SSE batch before it is sent. */
|
|
1534
|
+
applyDecisionBatch(taskId, requestKey, decisions, includeSubmitted = false, options = {}) {
|
|
1535
|
+
assertInternalDecisionKey(requestKey);
|
|
1536
|
+
const prior = this.readDecisionBatch(taskId, requestKey);
|
|
1537
|
+
if (prior)
|
|
1538
|
+
return prior;
|
|
1539
|
+
const current = this.requireTask(taskId);
|
|
1540
|
+
if (current.phase === 'terminal' || current.phase === 'error')
|
|
1541
|
+
throw new Error('task_terminal');
|
|
1542
|
+
assertReplayUpgradeReanchored(current);
|
|
1543
|
+
assertDecisionBatchFinality(decisions);
|
|
1544
|
+
assertAvailabilityLiveness(current, decisions, this.now());
|
|
1545
|
+
if (current.availability.recoveryStarted && !current.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error'))
|
|
1546
|
+
throw new Error('availability_terminal_policy_owned');
|
|
1547
|
+
for (const decision of decisions) {
|
|
1548
|
+
if (decision.kind === 'operation') {
|
|
1549
|
+
const checked = validateBookingReadAction(decision.action);
|
|
1550
|
+
if (!checked.ok)
|
|
1551
|
+
throw new Error(`invalid_action:${errorText(checked)}`);
|
|
1552
|
+
if (!validateBookingIntentProjection(decision.intent).ok)
|
|
1553
|
+
throw new Error('invalid_booking_intent');
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const run = this.ledger.db.transaction(() => {
|
|
1557
|
+
const events = [];
|
|
1558
|
+
const before = this.requireTask(taskId);
|
|
1559
|
+
assertReplayUpgradeReanchored(before);
|
|
1560
|
+
assertAvailabilityLiveness(before, decisions, this.now());
|
|
1561
|
+
if (before.availability.recoveryStarted && !before.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error'))
|
|
1562
|
+
throw new Error('availability_terminal_policy_owned');
|
|
1563
|
+
for (const decision of decisions) {
|
|
1564
|
+
if (decision.kind !== 'question')
|
|
1565
|
+
continue;
|
|
1566
|
+
const awaiting = before.awaitingApproval;
|
|
1567
|
+
if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingDigest(decision.question.blocker) !== bookingDigest(awaiting.blocker) || decision.question.approvalOptions.length !== awaiting.options.length || decision.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingDigest(option) === bookingDigest(approval))))
|
|
1568
|
+
throw new Error('approval_presentation_key_mismatch');
|
|
1569
|
+
this.ensureApprovalOffered(taskId, before.contextRef, awaiting, requestKey);
|
|
1570
|
+
}
|
|
1571
|
+
if (!options.suppressProgressStatuses) {
|
|
1572
|
+
if (includeSubmitted)
|
|
1573
|
+
events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'submitted' }));
|
|
1574
|
+
events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'working' }));
|
|
1575
|
+
}
|
|
1576
|
+
for (const decision of decisions) {
|
|
1577
|
+
if (decision.kind === 'operation') {
|
|
1578
|
+
events.push(this.issueOperationInTransaction(taskId, decision.action, decision.intent));
|
|
1579
|
+
events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'waiting_receipt' }));
|
|
1580
|
+
}
|
|
1581
|
+
else
|
|
1582
|
+
events.push(this.emitEventInTransaction(taskId, decision));
|
|
1583
|
+
}
|
|
1584
|
+
const state = this.requireTask(taskId);
|
|
1585
|
+
this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events }, `booking-copilot:decision-batch:${taskId}:${requestKey}`);
|
|
1586
|
+
return events;
|
|
1587
|
+
});
|
|
1588
|
+
return run.immediate();
|
|
1589
|
+
}
|
|
1590
|
+
issueOperation(taskId, candidate, intentProjection) {
|
|
1591
|
+
const validation = validateBookingReadAction(candidate);
|
|
1592
|
+
if (!validation.ok)
|
|
1593
|
+
throw new Error(`invalid_action:${errorText(validation)}`);
|
|
1594
|
+
assertTaskId(taskId);
|
|
1595
|
+
assertSafeRef(candidate.actionId);
|
|
1596
|
+
candidate.factRefs.forEach(assertSafeRef);
|
|
1597
|
+
return this.ledger.db.transaction(() => this.issueOperationInTransaction(taskId, candidate, intentProjection)).immediate();
|
|
1598
|
+
}
|
|
1599
|
+
issueOperationInTransaction(taskId, candidate, intentProjection) {
|
|
1600
|
+
if (intentProjection === undefined || intentProjection === null)
|
|
1601
|
+
throw new Error('booking_intent_required');
|
|
1602
|
+
assertSafeRef(candidate.actionId);
|
|
1603
|
+
candidate.factRefs.forEach(assertSafeRef);
|
|
1604
|
+
const state = this.requireTask(taskId);
|
|
1605
|
+
if (state.phase === 'terminal' || state.phase === 'error')
|
|
1606
|
+
throw new Error('task_terminal');
|
|
1607
|
+
assertReplayUpgradeReanchored(state);
|
|
1608
|
+
if (state.pendingAction) {
|
|
1609
|
+
if (state.pendingAction.actionId !== candidate.actionId)
|
|
1610
|
+
throw new Error('receipt_required');
|
|
1611
|
+
if (!matchesCheckpoint(candidate, state.pendingAction))
|
|
1612
|
+
throw new Error('action_conflict');
|
|
1613
|
+
return this.operationEvent(taskId, state.pendingAction, candidate);
|
|
1614
|
+
}
|
|
1615
|
+
if (candidate.contextRef !== state.contextRef)
|
|
1616
|
+
throw new Error('context_mismatch');
|
|
1617
|
+
if (!state.allowedActions.includes(candidate.kind))
|
|
1618
|
+
throw new Error('unsupported_action');
|
|
1619
|
+
if (candidate.expectedRevision !== state.revision)
|
|
1620
|
+
throw new Error('stale_revision');
|
|
1621
|
+
if (state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS)
|
|
1622
|
+
throw new Error('operation_limit_reached');
|
|
1623
|
+
if (candidate.relaxationApprovalRef)
|
|
1624
|
+
throw new Error('approval_ref_planner_owned_forbidden');
|
|
1625
|
+
if (!state.workspaceSnapshot)
|
|
1626
|
+
throw new Error(`ledger_corrupt:${taskId}:workspace_missing`);
|
|
1627
|
+
const intent = bindIntentCheckpoint(state, candidate, intentProjection);
|
|
1628
|
+
if (!actionAdvancesIntent(candidate, intent.projection))
|
|
1629
|
+
throw new Error('action_intent_mismatch');
|
|
1630
|
+
assertAvailabilityActionCompatible(state, candidate, this.now());
|
|
1631
|
+
if (!actionHitsCurrentOfferVersion(state.workspaceSnapshot, candidate, this.now()))
|
|
1632
|
+
throw new Error('offer_version_not_loaded');
|
|
1633
|
+
let action = candidate;
|
|
1634
|
+
const availability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, candidate);
|
|
1635
|
+
if (state.awaitingApproval) {
|
|
1636
|
+
const approval = state.awaitingApproval;
|
|
1637
|
+
if (!approval.approval || Date.parse(approval.expiresAt) <= Date.parse(this.now()))
|
|
1638
|
+
throw new Error('approval_expired');
|
|
1639
|
+
const ref = {
|
|
1640
|
+
approvalId: approval.approval.approvalId, blockerId: approval.blocker.blockerId,
|
|
1641
|
+
contextRef: state.contextRef, sourceTurnId: approval.sourceTurnId,
|
|
1642
|
+
presentationRequestKey: approval.presentationRequestKey, sourceActionId: approval.blocker.sourceActionId,
|
|
1643
|
+
targetActionId: candidate.actionId, sourceRevision: candidate.expectedRevision,
|
|
1644
|
+
targetActionKind: candidate.kind, to: approval.approval.to, expiresAt: approval.expiresAt,
|
|
1645
|
+
nonce: approval.nonce, sourceReceiptDigest: approval.blocker.sourceReceiptDigest,
|
|
1646
|
+
scope: approval.blocker.scope, code: approval.blocker.code, criterionPath: approval.blocker.criterionPath,
|
|
1647
|
+
valueDigest: approval.blocker.valueDigest,
|
|
1648
|
+
};
|
|
1649
|
+
action = { ...candidate, relaxationApprovalRef: ref };
|
|
1650
|
+
this.append(APPROVAL_CONSUMED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, approval, ref, approvalDigest: bookingDigest(ref) }, `booking-copilot:approval-consumed:${taskId}:${approval.approval.approvalId}:${approval.nonce}`);
|
|
1651
|
+
}
|
|
1652
|
+
const eventId = this.idFactory('event');
|
|
1653
|
+
const emittedAt = this.now();
|
|
1654
|
+
const sequence = state.lastSequence + 1;
|
|
1655
|
+
const event = { schemaVersion: 'booking.surface', eventId, taskId, contextRef: state.contextRef, sequence, emittedAt, kind: 'operation', action };
|
|
1656
|
+
const eventValidation = validateBookingSurfaceEvent(event);
|
|
1657
|
+
if (!eventValidation.ok)
|
|
1658
|
+
throw new Error(`invalid_operation_event:${errorText(eventValidation)}`);
|
|
1659
|
+
if (!state.lastTurnId)
|
|
1660
|
+
throw new Error(`ledger_corrupt:${taskId}:turn_id`);
|
|
1661
|
+
this.appendAction(taskId, state.contextRef, checkpointFor(action, eventId, sequence, emittedAt, state.lastTurnId), state.operationCount + 1, availability, intent);
|
|
1662
|
+
return event;
|
|
1663
|
+
}
|
|
1664
|
+
continueWithReceipt(turn) {
|
|
1665
|
+
const validation = validateBookingSurface(turn);
|
|
1666
|
+
if (!validation.ok)
|
|
1667
|
+
throw new Error(`invalid_receipt_continuation:${errorText(validation)}`);
|
|
1668
|
+
assertWorkspaceLoadedOfferRefsUnique(turn.workspace);
|
|
1669
|
+
assertCanonicalReceipt(turn.receipt);
|
|
1670
|
+
const run = this.ledger.db.transaction(() => {
|
|
1671
|
+
const state = this.requireTask(turn.taskId);
|
|
1672
|
+
assertReplayUpgradeReanchored(state);
|
|
1673
|
+
if (turn.workspace.contextRef !== state.contextRef || turn.receipt.contextRef !== state.contextRef)
|
|
1674
|
+
throw new Error('context_mismatch');
|
|
1675
|
+
const receiptWorkspace = bookingWorkspaceDigest(turn.workspace);
|
|
1676
|
+
const receiptWorkspaceSemantic = bookingWorkspaceSemanticDigest(turn.workspace);
|
|
1677
|
+
if (!state.workspaceSnapshot || !workspaceBoundaryMatches(state.workspaceSnapshot, turn.workspace))
|
|
1678
|
+
throw new Error('workspace_mismatch');
|
|
1679
|
+
const existing = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq`).all(this.ledger.tenant, turn.taskId, RECEIPT);
|
|
1680
|
+
const receiptDigest = canonicalReceiptDigest(turn.receipt);
|
|
1681
|
+
if (state.legacySuppressedReceiptActionIds?.includes(turn.receipt.actionId))
|
|
1682
|
+
throw new Error('request_key_invalidated');
|
|
1683
|
+
for (const row of existing) {
|
|
1684
|
+
const prior = JSON.parse(row.payload);
|
|
1685
|
+
if (prior.receipt.actionId !== turn.receipt.actionId)
|
|
1686
|
+
continue;
|
|
1687
|
+
if (prior.receiptDigest !== receiptDigest)
|
|
1688
|
+
throw new Error('receipt_conflict');
|
|
1689
|
+
if (prior.workspaceDigest !== receiptWorkspace)
|
|
1690
|
+
throw new Error('workspace_mismatch');
|
|
1691
|
+
return state;
|
|
1692
|
+
}
|
|
1693
|
+
if (state.phase === 'terminal' || state.phase === 'error')
|
|
1694
|
+
throw new Error('task_terminal');
|
|
1695
|
+
if (!state.pendingAction)
|
|
1696
|
+
throw new Error('unexpected_receipt');
|
|
1697
|
+
if (state.pendingAction.actionId !== turn.receipt.actionId)
|
|
1698
|
+
throw new Error('receipt_action_mismatch');
|
|
1699
|
+
if (turn.receipt.revision < state.pendingAction.expectedRevision)
|
|
1700
|
+
throw new Error('revision_regression');
|
|
1701
|
+
if (turn.receipt.resultContract.blockers.some((blocker) => blocker.sourceActionId !== state.pendingAction.actionId))
|
|
1702
|
+
throw new Error('receipt_source_action_mismatch');
|
|
1703
|
+
if (!workspacePostActionMatches(state.workspaceSnapshot, turn.workspace, state.pendingAction, turn.receipt))
|
|
1704
|
+
throw new Error('workspace_mismatch');
|
|
1705
|
+
if (!receiptObservationMatchesAction(turn.receipt.observation.kind, state.pendingAction.kind))
|
|
1706
|
+
throw new Error('receipt_observation_action_mismatch');
|
|
1707
|
+
if (!receiptTargetMatchesAction(turn.receipt, state.pendingAction))
|
|
1708
|
+
throw new Error('receipt_target_mismatch');
|
|
1709
|
+
assertOffersReceiptWorkspaceBinding(turn.receipt, turn.workspace, state.pendingAction);
|
|
1710
|
+
if (state.pendingAction.kind === 'offer.check' && turn.receipt.status === 'applied' && !verifiedOfferUnexpired(turn.workspace, this.now()))
|
|
1711
|
+
throw new Error('receipt_verified_offer_expired');
|
|
1712
|
+
// Failed/stale query receipts may carry a typed gap observation; only an
|
|
1713
|
+
// offers.state observation can claim passive offer provenance.
|
|
1714
|
+
if (turn.receipt.resultContract.relaxationsApplied.some((approval) => !this.approvalWasConsumed(turn.taskId, approval, state.pendingAction.actionId)))
|
|
1715
|
+
throw new Error('receipt_relaxation_unauthorized');
|
|
1716
|
+
const pending = state.pendingAction;
|
|
1717
|
+
if (turn.receipt.revision !== turn.workspace.revision)
|
|
1718
|
+
throw new Error('revision_mismatch');
|
|
1719
|
+
const availability = reduceAvailabilityReceipt(state.availability, turn.workspace, turn.receipt, pending);
|
|
1720
|
+
const availabilityTerminal = availabilityPolicyTerminal(availability);
|
|
1721
|
+
this.append(RECEIPT, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, receipt: turn.receipt, receiptDigest, operationCount: state.operationCount, workspaceDigest: receiptWorkspace, workspaceSemanticDigest: receiptWorkspaceSemantic, workspace: turn.workspace, availability, availabilityDigest: bookingDigest(availability), ...(availabilityTerminal ? { availabilityTerminal } : {}) }, `booking-copilot:receipt:${turn.taskId}:${turn.receipt.actionId}`);
|
|
1722
|
+
if (availabilityTerminalEndsTask(availabilityTerminal) || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) {
|
|
1723
|
+
const terminalState = this.requireTask(turn.taskId);
|
|
1724
|
+
const terminalEvent = this.terminalEvent(terminalState);
|
|
1725
|
+
this.appendTerminalEvent(turn.taskId, terminalEvent);
|
|
1726
|
+
this.append(DECISION_BATCH, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, requestKey: `receipt:${turn.receipt.actionId}:${receiptDigest}`, events: [terminalEvent] }, `booking-copilot:decision-batch:${turn.taskId}:receipt:${turn.receipt.actionId}:${receiptDigest}`);
|
|
1727
|
+
return terminalState;
|
|
1728
|
+
}
|
|
1729
|
+
if (pending.kind === 'checkout.prepare' && state.activeIntent?.projection.target === 'checkout.prepared') {
|
|
1730
|
+
const terminalState = this.requireTask(turn.taskId);
|
|
1731
|
+
const terminalEvent = this.checkoutHandoffTerminalEvent(terminalState, checkoutReceiptPreparedHandoff(turn.receipt));
|
|
1732
|
+
this.appendTerminalEvent(turn.taskId, terminalEvent);
|
|
1733
|
+
this.append(DECISION_BATCH, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, requestKey: `receipt:${turn.receipt.actionId}:${receiptDigest}`, events: [terminalEvent] }, `booking-copilot:decision-batch:${turn.taskId}:receipt:${turn.receipt.actionId}:${receiptDigest}`);
|
|
1734
|
+
return this.requireTask(turn.taskId);
|
|
1735
|
+
}
|
|
1736
|
+
const blocker = turn.receipt.resultContract.blockers[0];
|
|
1737
|
+
if (blocker) {
|
|
1738
|
+
// Never derive this claim from the injectable id factory: production
|
|
1739
|
+
// and crash proofs must not make a presentation nonce guessable from
|
|
1740
|
+
// deterministic task/event ids.
|
|
1741
|
+
const nonce = randomUUID();
|
|
1742
|
+
const sourceTurnId = state.pendingAction?.sourceTurnId ?? state.lastTurnId ?? `ordinal:${state.userTurnCount}`;
|
|
1743
|
+
const requestKey = presentationKey(turn.taskId, sourceTurnId, turn.receipt.actionId, receiptDigest);
|
|
1744
|
+
const approval = { blocker, options: [approvalOption(turn.taskId, state.contextRef, sourceTurnId, requestKey, blocker, 'prefer', nonce), approvalOption(turn.taskId, state.contextRef, sourceTurnId, requestKey, blocker, 'drop', nonce)], optionsEmitted: false, approval: undefined, nonce, expiresAt: new Date(Date.parse(this.now()) + this.approvalTtlMs).toISOString(), sourceTurnId, presentationRequestKey: requestKey };
|
|
1745
|
+
this.append(APPROVAL_GRANTED, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, approval, approvalDigest: bookingDigest(approval) }, `booking-copilot:approval-awaiting:${turn.taskId}:${turn.receipt.actionId}`);
|
|
1746
|
+
}
|
|
1747
|
+
return this.requireTask(turn.taskId);
|
|
1748
|
+
});
|
|
1749
|
+
return run.immediate();
|
|
1750
|
+
}
|
|
1751
|
+
emitEvent(taskId, draft) {
|
|
1752
|
+
return this.ledger.db.transaction(() => {
|
|
1753
|
+
const state = this.requireTask(taskId);
|
|
1754
|
+
if (state.phase === 'terminal' || state.phase === 'error')
|
|
1755
|
+
throw new Error('task_terminal');
|
|
1756
|
+
assertReplayUpgradeReanchored(state);
|
|
1757
|
+
return this.emitEventInTransaction(taskId, draft);
|
|
1758
|
+
}).immediate();
|
|
1759
|
+
}
|
|
1760
|
+
emitEventInTransaction(taskId, draft) {
|
|
1761
|
+
const state = this.requireTask(taskId);
|
|
1762
|
+
assertReplayUpgradeReanchored(state);
|
|
1763
|
+
const safeDraft = draft.kind === 'error'
|
|
1764
|
+
? {
|
|
1765
|
+
...draft,
|
|
1766
|
+
error: {
|
|
1767
|
+
...draft.error,
|
|
1768
|
+
code: normalizeBookingErrorCode(draft.error.code),
|
|
1769
|
+
message: safeBookingErrorMessage(normalizeBookingErrorCode(draft.error.code)),
|
|
1770
|
+
// An error event closes this task immediately. Until the public
|
|
1771
|
+
// protocol has an idempotent same-task retry command, claiming
|
|
1772
|
+
// that such an event is retryable would send the client into a
|
|
1773
|
+
// guaranteed task_terminal response on its next request.
|
|
1774
|
+
retryable: false,
|
|
1775
|
+
},
|
|
1776
|
+
}
|
|
1777
|
+
: draft;
|
|
1778
|
+
const event = { schemaVersion: 'booking.surface', eventId: this.idFactory('event'), taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), ...safeDraft };
|
|
1779
|
+
const checked = validateBookingSurfaceEvent(event);
|
|
1780
|
+
if (!checked.ok)
|
|
1781
|
+
throw new Error(`invalid_event:${errorText(checked)}`);
|
|
1782
|
+
this.append(EVENT, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: event.kind, ...(event.kind === 'status' ? { status: event.status } : {}), contentDigest: bookingDigest(safeDraft) }, `booking-copilot:event:${taskId}:${event.eventId}`);
|
|
1783
|
+
return event;
|
|
1784
|
+
}
|
|
1785
|
+
operationEvent(taskId, checkpoint, action) {
|
|
1786
|
+
const replayAction = action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef };
|
|
1787
|
+
return { schemaVersion: 'booking.surface', eventId: checkpoint.eventId, taskId, contextRef: checkpoint.contextRef, sequence: checkpoint.sequence, emittedAt: checkpoint.emittedAt, kind: 'operation', action: replayAction };
|
|
1788
|
+
}
|
|
1789
|
+
terminalEvent(state) {
|
|
1790
|
+
if (!taskHasTerminalBudget(state))
|
|
1791
|
+
throw new Error('task_not_terminal');
|
|
1792
|
+
const terminalCode = state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS ? 'operation_limit_reached' : state.availability.terminal?.code ?? 'operation_limit_reached';
|
|
1793
|
+
const event = { schemaVersion: 'booking.surface', eventId: `terminal-${state.taskId}-${state.operationCount}`, taskId: state.taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), kind: 'terminal', terminal: { status: 'stopped', summary: terminalCode, factRefs: [] } };
|
|
1794
|
+
const checked = validateBookingSurfaceEvent(event);
|
|
1795
|
+
if (!checked.ok)
|
|
1796
|
+
throw new Error(`invalid_terminal_event:${errorText(checked)}`);
|
|
1797
|
+
return event;
|
|
1798
|
+
}
|
|
1799
|
+
checkoutHandoffTerminalEvent(state, prepared) {
|
|
1800
|
+
const event = { schemaVersion: 'booking.surface', eventId: `terminal-${state.taskId}-${state.operationCount}`, taskId: state.taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), kind: 'terminal', terminal: { status: prepared ? 'completed' : 'stopped', summary: prepared ? 'checkout_handoff_prepared' : 'checkout_handoff_not_prepared', factRefs: [] } };
|
|
1801
|
+
const checked = validateBookingSurfaceEvent(event);
|
|
1802
|
+
if (!checked.ok)
|
|
1803
|
+
throw new Error(`invalid_terminal_event:${errorText(checked)}`);
|
|
1804
|
+
return event;
|
|
1805
|
+
}
|
|
1806
|
+
requireTask(taskId) { const state = this.resumeTask(taskId); if (!state)
|
|
1807
|
+
throw new Error('task_not_found'); return state; }
|
|
1808
|
+
requestBinding(requestKey) {
|
|
1809
|
+
const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND kind = ? ORDER BY seq DESC`).all(this.ledger.tenant, REQUEST_BINDING);
|
|
1810
|
+
for (const row of rows) {
|
|
1811
|
+
const binding = JSON.parse(row.payload);
|
|
1812
|
+
if (binding.requestKey === requestKey) {
|
|
1813
|
+
const state = this.resumeTask(binding.taskId);
|
|
1814
|
+
if (state?.legacySuppressedRequestBindingKeys?.includes(requestKey))
|
|
1815
|
+
throw new Error('request_key_invalidated');
|
|
1816
|
+
return binding;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return null;
|
|
1820
|
+
}
|
|
1821
|
+
requestBindingPayload(requestKey, turn, input) {
|
|
1822
|
+
assertPrincipal(input.principal);
|
|
1823
|
+
assertRequestKey(requestKey);
|
|
1824
|
+
if (input.taskHandle)
|
|
1825
|
+
assertSafeRef(input.taskHandle);
|
|
1826
|
+
return {
|
|
1827
|
+
requestKey,
|
|
1828
|
+
requestDigest: bookingTurnDigest(turn),
|
|
1829
|
+
turnId: turn.turnId,
|
|
1830
|
+
workspaceDigest: bookingWorkspaceDigest(turn.workspace),
|
|
1831
|
+
workspaceSemanticDigest: bookingWorkspaceSemanticDigest(turn.workspace),
|
|
1832
|
+
surface: turn.workspace.surface,
|
|
1833
|
+
capabilityDigest: bookingDigest(turn.workspace.capabilities),
|
|
1834
|
+
principalDigest: bookingDigest({ subject: input.principal.subject }),
|
|
1835
|
+
scopeDigest: bookingDigest({ scope: input.principal.scope }),
|
|
1836
|
+
...(input.taskHandle ? { taskHandle: input.taskHandle } : {}),
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
appendRequestBindingInTransaction(requestKey, turn, input) {
|
|
1840
|
+
assertRequestKey(requestKey);
|
|
1841
|
+
assertPrincipal(input.principal);
|
|
1842
|
+
const payload = this.requestBindingPayload(requestKey, turn, input);
|
|
1843
|
+
if (input.taskHandle) {
|
|
1844
|
+
const prior = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND kind = ?`).all(this.ledger.tenant, REQUEST_BINDING);
|
|
1845
|
+
for (const row of prior) {
|
|
1846
|
+
const binding = JSON.parse(row.payload);
|
|
1847
|
+
if (binding.taskHandle !== input.taskHandle)
|
|
1848
|
+
continue;
|
|
1849
|
+
if (binding.principalDigest !== payload.principalDigest || binding.scopeDigest !== payload.scopeDigest)
|
|
1850
|
+
throw new Error('principal_conflict');
|
|
1851
|
+
if (binding.taskId !== turn.taskId || binding.contextRef !== turn.workspace.contextRef)
|
|
1852
|
+
throw new Error('task_handle_conflict');
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
const existing = this.requestBinding(requestKey);
|
|
1856
|
+
if (existing) {
|
|
1857
|
+
if (existing.taskId !== turn.taskId || existing.contextRef !== turn.workspace.contextRef || existing.requestDigest !== payload.requestDigest || existing.turnId !== payload.turnId || existing.workspaceDigest !== payload.workspaceDigest || existing.workspaceSemanticDigest !== payload.workspaceSemanticDigest || existing.surface !== payload.surface || existing.capabilityDigest !== payload.capabilityDigest || existing.principalDigest !== payload.principalDigest || existing.scopeDigest !== payload.scopeDigest || existing.taskHandle !== payload.taskHandle)
|
|
1858
|
+
throw new Error('request_conflict');
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
this.append(REQUEST_BINDING, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: turn.workspace.contextRef, ...payload }, `booking-copilot:request-binding:${requestKey}`);
|
|
1862
|
+
}
|
|
1863
|
+
normalizeDecisionBatchFromDurableRows(taskId, batchEvents, batchSeq) {
|
|
1864
|
+
const individualRows = this.ledger.db.prepare(`SELECT kind, payload FROM events WHERE tenant_id = ? AND run_id = ? AND seq < ? AND kind IN (?, ?) ORDER BY seq`).all(this.ledger.tenant, taskId, batchSeq, EVENT, ACTION);
|
|
1865
|
+
const turnRows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? AND seq < ?`).all(this.ledger.tenant, taskId, TURN, batchSeq);
|
|
1866
|
+
const sourceWorkspaces = new Map();
|
|
1867
|
+
for (const row of turnRows) {
|
|
1868
|
+
try {
|
|
1869
|
+
const turn = JSON.parse(row.payload);
|
|
1870
|
+
sourceWorkspaces.set(turn.turnId, normalizeWorkspaceForReplay(turn.workspace));
|
|
1871
|
+
}
|
|
1872
|
+
catch {
|
|
1873
|
+
return null;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
const recordedEvents = new Map();
|
|
1877
|
+
const recordedActions = new Map();
|
|
1878
|
+
for (const row of individualRows) {
|
|
1879
|
+
if (row.kind === EVENT) {
|
|
1880
|
+
const payload = JSON.parse(row.payload);
|
|
1881
|
+
const key = consumedIdentityKey(payload.eventId, payload.sequence);
|
|
1882
|
+
if (recordedEvents.has(key) || recordedActions.has(key))
|
|
1883
|
+
return null;
|
|
1884
|
+
recordedEvents.set(key, payload);
|
|
1885
|
+
}
|
|
1886
|
+
else if (row.kind === ACTION) {
|
|
1887
|
+
const payload = JSON.parse(row.payload);
|
|
1888
|
+
let action = payload.action;
|
|
1889
|
+
try {
|
|
1890
|
+
action = normalizeActionCheckpointForReplay(action, sourceWorkspaces.get(action.sourceTurnId));
|
|
1891
|
+
}
|
|
1892
|
+
catch {
|
|
1893
|
+
return null;
|
|
1894
|
+
}
|
|
1895
|
+
const key = consumedIdentityKey(action.eventId, action.sequence);
|
|
1896
|
+
if (recordedEvents.has(key) || recordedActions.has(key))
|
|
1897
|
+
return null;
|
|
1898
|
+
recordedActions.set(key, action);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
let lastBatchSequence = -1;
|
|
1902
|
+
const seen = new Set();
|
|
1903
|
+
const normalizedEvents = [];
|
|
1904
|
+
for (const event of batchEvents) {
|
|
1905
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence)
|
|
1906
|
+
return null;
|
|
1907
|
+
lastBatchSequence = event.sequence;
|
|
1908
|
+
const key = consumedIdentityKey(event.eventId, event.sequence);
|
|
1909
|
+
if (seen.has(key))
|
|
1910
|
+
return null;
|
|
1911
|
+
seen.add(key);
|
|
1912
|
+
if (event.kind === 'operation') {
|
|
1913
|
+
const checkpoint = recordedActions.get(key);
|
|
1914
|
+
if (!checkpoint || !operationEventMatchesAction(event, checkpoint))
|
|
1915
|
+
return null;
|
|
1916
|
+
const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) };
|
|
1917
|
+
if (!validateBookingSurfaceEvent(normalized).ok)
|
|
1918
|
+
return null;
|
|
1919
|
+
normalizedEvents.push(normalized);
|
|
1920
|
+
}
|
|
1921
|
+
else {
|
|
1922
|
+
const recorded = recordedEvents.get(key);
|
|
1923
|
+
if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event))
|
|
1924
|
+
return null;
|
|
1925
|
+
normalizedEvents.push(event);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
return normalizedEvents;
|
|
1929
|
+
}
|
|
1930
|
+
rows(taskId) { return this.ledger.db.prepare(`SELECT seq, kind, payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ORDER BY seq`).all(this.ledger.tenant, taskId, STARTED, TURN, ACTION, RECEIPT, EVENT, APPROVAL_GRANTED, APPROVAL_OFFERED, APPROVAL_CONSUMED, DECISION_BATCH, REQUEST_BINDING); }
|
|
1931
|
+
append(kind, taskId, payload, idemKey) {
|
|
1932
|
+
const result = this.ledger.db.prepare(`INSERT OR IGNORE INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), ACTOR, kind, taskId, JSON.stringify(payload), idemKey, taskId);
|
|
1933
|
+
if (result.changes === 0)
|
|
1934
|
+
throw new Error(`ledger_write_failed:${kind}`);
|
|
1935
|
+
}
|
|
1936
|
+
appendTurn(taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, ordinal, turnId, workspace, preserveIntent = false) { if (!workspace)
|
|
1937
|
+
throw new Error('ledger_write_failed:turn_workspace'); this.append(TURN, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, workspace, turnId, preserveIntent }, `booking-copilot:turn:${taskId}:${turnId}`); }
|
|
1938
|
+
appendApproval(taskId, contextRef, approval) { this.append(APPROVAL_GRANTED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingDigest(approval) }, `booking-copilot:approval-granted:${taskId}:${approval.approval?.approvalId ?? 'pending'}:${approval.nonce}`); }
|
|
1939
|
+
appendAction(taskId, contextRef, action, operationCount, availability, intent) { this.append(ACTION, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, action, operationCount, availability, availabilityDigest: bookingDigest(availability), intent }, `booking-copilot:action:${taskId}:${action.actionId}`); }
|
|
1940
|
+
appendTerminalEvent(taskId, event) {
|
|
1941
|
+
const payload = { schema: LEDGER_SCHEMA, taskId, contextRef: event.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: 'terminal', contentDigest: bookingDigest(event.terminal) };
|
|
1942
|
+
const idemKey = `booking-copilot:terminal-event:${taskId}`;
|
|
1943
|
+
const result = this.ledger.db.prepare(`INSERT OR IGNORE INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), ACTOR, EVENT, taskId, JSON.stringify(payload), idemKey, taskId);
|
|
1944
|
+
if (result.changes === 0) {
|
|
1945
|
+
const existing = this.ledger.db.prepare('SELECT payload FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey);
|
|
1946
|
+
if (!existing || JSON.parse(existing.payload).contentDigest !== payload.contentDigest)
|
|
1947
|
+
throw new Error('ledger_write_failed:terminal_event');
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
ensureApprovalOffered(taskId, contextRef, approval, requestKey) {
|
|
1951
|
+
if (requestKey !== approval.presentationRequestKey)
|
|
1952
|
+
throw new Error('approval_presentation_key_mismatch');
|
|
1953
|
+
const idemKey = `booking-copilot:approval-offered:${taskId}:${requestKey}`;
|
|
1954
|
+
const exists = this.ledger.db.prepare('SELECT 1 AS present FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey);
|
|
1955
|
+
if (!exists)
|
|
1956
|
+
this.append(APPROVAL_OFFERED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingDigest(approval) }, idemKey);
|
|
1957
|
+
}
|
|
1958
|
+
approvalWasConsumed(taskId, approval, targetActionId) {
|
|
1959
|
+
const state = this.resumeTask(taskId);
|
|
1960
|
+
if (state?.legacySuppressedApprovalTargetActionIds?.includes(targetActionId))
|
|
1961
|
+
return false;
|
|
1962
|
+
const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, APPROVAL_CONSUMED);
|
|
1963
|
+
return rows.some(({ payload }) => {
|
|
1964
|
+
const consumed = JSON.parse(payload);
|
|
1965
|
+
return consumed.ref?.targetActionId === targetActionId && consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval);
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
}
|