@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,2349 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { ensureLedger } from '../src/state-ledger.js';
|
|
9
|
+
import { bookingDigest, } from '../src/booking-surface/runtime.js';
|
|
10
|
+
import { BookingCopilotProofRuntime as BookingCopilotTaskRuntime } from './booking-copilot-proof-runtime.js';
|
|
11
|
+
import { startBookingCopilotServer } from '../src/booking-surface/server.js';
|
|
12
|
+
import { BOOKING_COPILOT_MAX_OPERATIONS, BOOKING_FULL_JOURNEY_ACTION_KINDS, BOOKING_READ_ACTION_KINDS, BOOKING_SURFACE_FEATURES, BOOKING_SURFACE_SCHEMA_SHA256, BOOKING_SURFACE_SCHEMA_VERSION } from '../src/booking-surface/contracts.js';
|
|
13
|
+
import { validateBookingSurface } from '../src/booking-surface/validation.js';
|
|
14
|
+
import { normalizeBookingErrorCode, safeBookingErrorMessage } from '../src/booking-surface/error-codes.js';
|
|
15
|
+
for (const [raw, code] of [
|
|
16
|
+
['planner_invalid_tool_arguments', 'PLANNER_PROVIDER_RESPONSE_INVALID'],
|
|
17
|
+
['planner_terminal_intent_target_unachieved', 'PLANNER_PROVIDER_RESPONSE_INVALID'],
|
|
18
|
+
['planner_forbidden_tool', 'UNSUPPORTED_ACTION'],
|
|
19
|
+
['action_intent_mismatch', 'INVALID_ACTION'],
|
|
20
|
+
['approval_replayed', 'ACTION_CONFLICT'],
|
|
21
|
+
['request_conflict', 'ACTION_CONFLICT'],
|
|
22
|
+
['turn_conflict', 'ACTION_CONFLICT'],
|
|
23
|
+
]) {
|
|
24
|
+
assert.equal(normalizeBookingErrorCode(new Error(raw)), code, `${raw} has a precise closed boundary classification`);
|
|
25
|
+
assert.notEqual(safeBookingErrorMessage(code), 'The planner request failed at the typed runtime boundary.', `${raw} is not collapsed into the generic planner failure`);
|
|
26
|
+
}
|
|
27
|
+
const stateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-runtime-'));
|
|
28
|
+
const workspace = (revision = 0) => ({
|
|
29
|
+
schemaVersion: 'booking.surface', contextRef: 'ctx-v2', surface: 'tenant', revision,
|
|
30
|
+
locale: 'en-US', currency: 'AED', searchDraft: {}, results: { status: 'idle' },
|
|
31
|
+
visibleHotels: [], loadedOffers: [], shortlistedOfferRefs: [],
|
|
32
|
+
capabilities: { surface: 'tenant', allowedActions: [...BOOKING_FULL_JOURNEY_ACTION_KINDS] },
|
|
33
|
+
});
|
|
34
|
+
const loadedOffer = (offerRef, hotelRef, offerVersionRef = `${offerRef}:v1`) => ({ offerRef, offerVersionRef, hotelRef, evidenceLevel: 'rate_loaded', factRefs: [] });
|
|
35
|
+
const verifiedCapability = (offerRef, offerVersionRef = `${offerRef}:v1`, expiresAt = '2026-09-01T10:30:00.000Z') => ({ offerRef, offerVersionRef, verifiedOfferRef: `verified-${offerRef}`, expiresAt });
|
|
36
|
+
const availabilityObservation = (offerRef, checkedOfferVersionRef = `${offerRef}:v1`, available = false, currentOfferVersionRef) => ({ kind: 'offer.availability', offerRef, checkedOfferVersionRef, currentOfferVersionRef: available ? (currentOfferVersionRef ?? checkedOfferVersionRef) : undefined, available, changedFactRefs: [], gapCodes: [] });
|
|
37
|
+
const oldWorkspaceDigest = (value) => {
|
|
38
|
+
const { contextRef: _contextRef, ...withoutContext } = value;
|
|
39
|
+
return bookingDigest(withoutContext);
|
|
40
|
+
};
|
|
41
|
+
const oldWorkspaceSemanticDigest = (value) => {
|
|
42
|
+
const { contextRef: _contextRef, revision: _revision, ...semantic } = value;
|
|
43
|
+
return bookingDigest(semantic);
|
|
44
|
+
};
|
|
45
|
+
const legacyWorkspace = (value, verifiedOfferRef) => {
|
|
46
|
+
const workspaceValue = structuredClone(value);
|
|
47
|
+
const loadedOffers = Array.isArray(workspaceValue.loadedOffers) ? workspaceValue.loadedOffers : [];
|
|
48
|
+
workspaceValue.loadedOffers = loadedOffers.map((loaded) => {
|
|
49
|
+
const oldOffer = { ...loaded };
|
|
50
|
+
delete oldOffer.offerVersionRef;
|
|
51
|
+
return oldOffer;
|
|
52
|
+
});
|
|
53
|
+
delete workspaceValue.verifiedOffer;
|
|
54
|
+
if (verifiedOfferRef !== undefined)
|
|
55
|
+
workspaceValue.verifiedOfferRef = verifiedOfferRef;
|
|
56
|
+
return workspaceValue;
|
|
57
|
+
};
|
|
58
|
+
const legacyAvailability = (value, workspaceDigest) => {
|
|
59
|
+
const availability = structuredClone(value);
|
|
60
|
+
const hotels = availability.hotels;
|
|
61
|
+
if (hotels && typeof hotels === 'object' && !Array.isArray(hotels)) {
|
|
62
|
+
for (const hotel of Object.values(hotels)) {
|
|
63
|
+
if (!hotel || typeof hotel !== 'object' || Array.isArray(hotel))
|
|
64
|
+
continue;
|
|
65
|
+
const oldHotel = hotel;
|
|
66
|
+
delete oldHotel.tombstonedOfferVersionRefs;
|
|
67
|
+
const generation = oldHotel.currentGeneration;
|
|
68
|
+
if (workspaceDigest && generation && typeof generation === 'object' && !Array.isArray(generation)) {
|
|
69
|
+
const source = generation.source;
|
|
70
|
+
if (source && typeof source === 'object' && !Array.isArray(source) && source.kind === 'workspace_snapshot')
|
|
71
|
+
source.workspaceDigest = workspaceDigest;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(availability.attempts)) {
|
|
76
|
+
availability.attempts = availability.attempts.map((attempt) => {
|
|
77
|
+
if (!attempt || typeof attempt !== 'object' || Array.isArray(attempt))
|
|
78
|
+
return attempt;
|
|
79
|
+
const oldAttempt = { ...attempt };
|
|
80
|
+
delete oldAttempt.offerVersionRef;
|
|
81
|
+
return oldAttempt;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return availability;
|
|
85
|
+
};
|
|
86
|
+
const stripLegacyReceipt = (value) => {
|
|
87
|
+
const receipt = structuredClone(value);
|
|
88
|
+
const observation = receipt.observation;
|
|
89
|
+
if (observation && typeof observation === 'object' && !Array.isArray(observation)) {
|
|
90
|
+
const oldObservation = observation;
|
|
91
|
+
delete oldObservation.offerVersionRef;
|
|
92
|
+
delete oldObservation.checkedOfferVersionRef;
|
|
93
|
+
delete oldObservation.currentOfferVersionRef;
|
|
94
|
+
}
|
|
95
|
+
return receipt;
|
|
96
|
+
};
|
|
97
|
+
const legacyTurnDigest = (taskId, turnId, workspaceValue, text) => bookingDigest({ schemaVersion: 'booking.surface', kind: 'user.turn', taskId, turnId, workspace: workspaceValue, request: { text } });
|
|
98
|
+
const turn = (taskId, revision = 0) => ({
|
|
99
|
+
schemaVersion: 'booking.surface', kind: 'user.turn', taskId,
|
|
100
|
+
turnId: `${taskId}-turn-${revision}`,
|
|
101
|
+
workspace: workspace(revision), request: { text: 'find hotels in Dubai' },
|
|
102
|
+
});
|
|
103
|
+
const action = (id, revision = 0, extra = {}) => ({
|
|
104
|
+
schemaVersion: 'booking.surface', kind: 'search.run', actionId: id,
|
|
105
|
+
contextRef: 'ctx-v2', expectedRevision: revision, reason: 'search current workspace', factRefs: [], input: {}, ...extra,
|
|
106
|
+
});
|
|
107
|
+
const searchResultsIntent = { schemaVersion: 'booking.intent.v1', target: 'search.results' };
|
|
108
|
+
const offerVerifiedIntent = { schemaVersion: 'booking.intent.v1', target: 'offer.verified' };
|
|
109
|
+
const checkoutPreparedIntent = { schemaVersion: 'booking.intent.v1', target: 'checkout.prepared' };
|
|
110
|
+
const offersLoadedIntent = { schemaVersion: 'booking.intent.v1', target: 'offers.loaded' };
|
|
111
|
+
const AUTHORIZATION_HEADER = 'authorization';
|
|
112
|
+
const dispatchFixtureAuth = ['fixture', 'dispatch', 'log', 'key'].join('-');
|
|
113
|
+
const DISPATCH_LOG_HOSTILE_MARKERS = [
|
|
114
|
+
'ISSUE_329_USER_MARKER',
|
|
115
|
+
'task-hostile-329',
|
|
116
|
+
'/tmp/issue-329-secret-path',
|
|
117
|
+
'sk_test_329_FAKE_ONLY',
|
|
118
|
+
dispatchFixtureAuth,
|
|
119
|
+
];
|
|
120
|
+
async function runDispatchLogChild() {
|
|
121
|
+
const childRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-dispatch-log-child-'));
|
|
122
|
+
class DispatchFixtureRuntime extends BookingCopilotTaskRuntime {
|
|
123
|
+
resumeTask(taskId) {
|
|
124
|
+
if (taskId === 'task-329-known')
|
|
125
|
+
throw new Error('workspace_mismatch');
|
|
126
|
+
if (taskId === 'task-329-unknown')
|
|
127
|
+
throw new Error(`unknown_dispatch_reason:${DISPATCH_LOG_HOSTILE_MARKERS.join(':')}`);
|
|
128
|
+
if (taskId === 'task-329-suffixed')
|
|
129
|
+
throw new Error(`workspace_mismatch:${DISPATCH_LOG_HOSTILE_MARKERS.join(':')}`);
|
|
130
|
+
return super.resumeTask(taskId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const childLedger = ensureLedger(childRoot);
|
|
134
|
+
const runtime = new DispatchFixtureRuntime(childLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
135
|
+
const server = await startBookingCopilotServer({
|
|
136
|
+
apiKey: dispatchFixtureAuth,
|
|
137
|
+
runtime,
|
|
138
|
+
plannerFactory: () => ({ next: async () => [] }),
|
|
139
|
+
});
|
|
140
|
+
process.stdout.write(`READY ${server.port}\n`);
|
|
141
|
+
const shutdown = async () => {
|
|
142
|
+
await server.close();
|
|
143
|
+
childLedger.close();
|
|
144
|
+
rmSync(childRoot, { recursive: true, force: true });
|
|
145
|
+
process.exit(0);
|
|
146
|
+
};
|
|
147
|
+
process.once('SIGTERM', () => { void shutdown(); });
|
|
148
|
+
await new Promise(() => { });
|
|
149
|
+
}
|
|
150
|
+
if (process.argv[2] === '--dispatch-log-child') {
|
|
151
|
+
await runDispatchLogChild();
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
async function assertDispatchRejectionStderr() {
|
|
155
|
+
const script = fileURLToPath(import.meta.url);
|
|
156
|
+
const tsx = fileURLToPath(new URL('../node_modules/tsx/dist/cli.mjs', import.meta.url));
|
|
157
|
+
const child = spawn(process.execPath, [tsx, script, '--dispatch-log-child'], {
|
|
158
|
+
cwd: process.cwd(), env: {
|
|
159
|
+
GOTRY_SESSION_LIVE: '0',
|
|
160
|
+
GOTRY_HBCLI_LIVE: '0',
|
|
161
|
+
GOTRY_HOTELBYTE_SKILLS_LIVE: '0',
|
|
162
|
+
}, stdio: ['ignore', 'pipe', 'pipe'],
|
|
163
|
+
});
|
|
164
|
+
const stdoutChunks = [];
|
|
165
|
+
const stderrChunks = [];
|
|
166
|
+
child.stdout.on('data', (chunk) => { stdoutChunks.push(Buffer.from(chunk)); });
|
|
167
|
+
child.stderr.on('data', (chunk) => { stderrChunks.push(Buffer.from(chunk)); });
|
|
168
|
+
const exit = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal })));
|
|
169
|
+
const cases = [
|
|
170
|
+
['task-329-known', 'WORKSPACE_MISMATCH', 'workspace_mismatch'],
|
|
171
|
+
['task-329-unknown', 'PLANNER_FAILED', 'UNCLASSIFIED'],
|
|
172
|
+
['task-329-suffixed', 'WORKSPACE_MISMATCH', 'UNCLASSIFIED'],
|
|
173
|
+
];
|
|
174
|
+
try {
|
|
175
|
+
const deadline = Date.now() + 10_000;
|
|
176
|
+
while (!Buffer.concat(stdoutChunks).toString('utf8').includes('READY ')) {
|
|
177
|
+
if (Date.now() > deadline)
|
|
178
|
+
throw new Error(`dispatch_log_child_not_ready:${Buffer.concat(stderrChunks).toString('utf8')}`);
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
180
|
+
}
|
|
181
|
+
const port = Number(Buffer.concat(stdoutChunks).toString('utf8').match(/READY (\d+)/)?.[1]);
|
|
182
|
+
assert.ok(Number.isInteger(port) && port > 0);
|
|
183
|
+
const headers = { [AUTHORIZATION_HEADER]: ['Bearer', dispatchFixtureAuth].join(' '), 'content-type': 'application/json', 'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION, 'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_SHA256 };
|
|
184
|
+
for (const [taskId, expectedCode, expectedReason] of cases) {
|
|
185
|
+
const response = await fetch(`http://127.0.0.1:${port}/a2a/booking-copilot/turn`, { method: 'POST', headers, body: JSON.stringify(turn(taskId)) });
|
|
186
|
+
assert.equal(response.status, 409, `${taskId} keeps the dispatch conflict status`);
|
|
187
|
+
const responseText = JSON.stringify(await response.json());
|
|
188
|
+
assert.equal(responseText, JSON.stringify({ error: { code: expectedCode } }), `${taskId} keeps the typed safe response body`);
|
|
189
|
+
for (const marker of DISPATCH_LOG_HOSTILE_MARKERS) {
|
|
190
|
+
assert.equal(responseText.includes(marker), false, `${taskId} response omits ${marker}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
child.kill('SIGTERM');
|
|
196
|
+
let timeout;
|
|
197
|
+
const gracefullyExited = await Promise.race([
|
|
198
|
+
exit,
|
|
199
|
+
new Promise((resolve) => { timeout = setTimeout(() => resolve(null), 1_000); }),
|
|
200
|
+
]);
|
|
201
|
+
if (timeout)
|
|
202
|
+
clearTimeout(timeout);
|
|
203
|
+
if (gracefullyExited === null) {
|
|
204
|
+
child.kill('SIGKILL');
|
|
205
|
+
await exit;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const childExit = await exit;
|
|
209
|
+
assert.deepEqual(childExit, { code: 0, signal: null }, 'dispatch log fixture shuts down cleanly');
|
|
210
|
+
const stderrBytes = Buffer.concat(stderrChunks);
|
|
211
|
+
const stderr = stderrBytes.toString('utf8');
|
|
212
|
+
const entries = stderr.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
213
|
+
assert.equal(entries.length, cases.length, 'child stderr contains one structured entry per dispatch rejection');
|
|
214
|
+
assert.ok(entries.every((entry) => Object.keys(entry).sort().join(',') === 'code,reason'), 'stderr entries contain only code and reason');
|
|
215
|
+
assert.deepEqual(entries, cases.map(([, code, reason]) => ({ code, reason })), 'known and unclassified reasons are exact and ordered');
|
|
216
|
+
for (const marker of DISPATCH_LOG_HOSTILE_MARKERS) {
|
|
217
|
+
assert.equal(stderrBytes.includes(Buffer.from(marker)), false, `stderr bytes omit ${marker}`);
|
|
218
|
+
}
|
|
219
|
+
console.log(`BOOKING DISPATCH LOG PROOF: HTTP 409/child stderr bytes OK (stderr_sha256=${createHash('sha256').update(stderrBytes).digest('hex')})`);
|
|
220
|
+
}
|
|
221
|
+
await assertDispatchRejectionStderr();
|
|
222
|
+
// Repaired planner fact refs are evidence citations, never capability tokens.
|
|
223
|
+
// Even authority-looking values must not grant an action, a newer offer
|
|
224
|
+
// version, or the verified tuple required to prepare Checkout.
|
|
225
|
+
const repairedFactRef = `modelref:${'a'.repeat(64)}`;
|
|
226
|
+
{
|
|
227
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-factref-action-authority-'));
|
|
228
|
+
const ledger = ensureLedger(root);
|
|
229
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
230
|
+
const limitedWorkspace = {
|
|
231
|
+
...workspace(0),
|
|
232
|
+
capabilities: { surface: 'tenant', allowedActions: ['search.run'] },
|
|
233
|
+
};
|
|
234
|
+
const t = rt.startTask({ ...turn('task-factref-action-authority'), workspace: limitedWorkspace });
|
|
235
|
+
const before = ledger.countEvents();
|
|
236
|
+
assert.throws(() => rt.issueOperation(t.taskId, {
|
|
237
|
+
...action('factref-forged-action', 0, { factRefs: [repairedFactRef, 'checkout.prepare'] }),
|
|
238
|
+
kind: 'checkout.prepare',
|
|
239
|
+
input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: 'verified-offer-a' },
|
|
240
|
+
}), /unsupported_action/, 'factRefs cannot add an action to the surface allowlist');
|
|
241
|
+
assert.equal(ledger.countEvents(), before, 'rejected factRef authority does not persist an operation');
|
|
242
|
+
ledger.close();
|
|
243
|
+
rmSync(root, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
{
|
|
246
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-factref-offer-authority-'));
|
|
247
|
+
const ledger = ensureLedger(root);
|
|
248
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2' });
|
|
249
|
+
const offerWorkspace = {
|
|
250
|
+
...workspace(0),
|
|
251
|
+
visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }],
|
|
252
|
+
loadedOffers: [loadedOffer('offer-a', 'hotel-a', 'offer-a:v1')],
|
|
253
|
+
};
|
|
254
|
+
const t = rt.startTask({ ...turn('task-factref-offer-authority'), workspace: offerWorkspace });
|
|
255
|
+
const before = ledger.countEvents();
|
|
256
|
+
assert.throws(() => rt.issueOperation(t.taskId, {
|
|
257
|
+
...action('factref-forged-version', 0, { factRefs: [repairedFactRef, 'offer-a:v2'] }),
|
|
258
|
+
kind: 'offer.check',
|
|
259
|
+
input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v2' },
|
|
260
|
+
}), /offer_version_not_loaded/, 'factRefs cannot promote an unloaded offer version');
|
|
261
|
+
assert.equal(ledger.countEvents(), before, 'rejected factRef version does not persist an operation');
|
|
262
|
+
ledger.close();
|
|
263
|
+
rmSync(root, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
{
|
|
266
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-factref-verified-authority-'));
|
|
267
|
+
const ledger = ensureLedger(root);
|
|
268
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
269
|
+
const offerWorkspace = {
|
|
270
|
+
...workspace(0),
|
|
271
|
+
visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }],
|
|
272
|
+
loadedOffers: [loadedOffer('offer-a', 'hotel-a', 'offer-a:v1')],
|
|
273
|
+
selectedOfferRef: 'offer-a',
|
|
274
|
+
};
|
|
275
|
+
const t = rt.startTask({ ...turn('task-factref-verified-authority'), workspace: offerWorkspace });
|
|
276
|
+
const before = ledger.countEvents();
|
|
277
|
+
assert.throws(() => rt.issueOperation(t.taskId, {
|
|
278
|
+
...action('factref-forged-verified', 0, { factRefs: [repairedFactRef, 'verified-offer-a'] }),
|
|
279
|
+
kind: 'checkout.prepare',
|
|
280
|
+
input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: 'verified-offer-a' },
|
|
281
|
+
}), /offer_version_not_loaded/, 'factRefs cannot create verified-offer authority');
|
|
282
|
+
assert.equal(ledger.countEvents(), before, 'rejected factRef verification does not persist an operation');
|
|
283
|
+
ledger.close();
|
|
284
|
+
rmSync(root, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
let id = 0;
|
|
287
|
+
const runtime = new BookingCopilotTaskRuntime(ensureLedger(stateRoot), {
|
|
288
|
+
idFactory: (prefix) => `${prefix}-${++id}`,
|
|
289
|
+
now: () => '2026-09-01T10:00:00.000Z',
|
|
290
|
+
});
|
|
291
|
+
const task = runtime.startTask(turn('task-v2'));
|
|
292
|
+
assert.equal(task.taskId, 'task-v2');
|
|
293
|
+
assert.throws(() => runtime.startTask({ ...turn('task-no-id'), taskId: undefined }), /invalid_planner_turn/);
|
|
294
|
+
{
|
|
295
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-same-revision-workspace-'));
|
|
296
|
+
const ledger = ensureLedger(root);
|
|
297
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
298
|
+
const taskId = 'task-same-revision-workspace';
|
|
299
|
+
const baseline = {
|
|
300
|
+
...workspace(0),
|
|
301
|
+
visibleHotels: [{ hotelRef: 'hotel-injected', name: 'Injected Hotel', factRefs: [] }],
|
|
302
|
+
loadedOffers: [loadedOffer('offer-injected', 'hotel-injected'), loadedOffer('offer-peer', 'hotel-injected')],
|
|
303
|
+
shortlistedOfferRefs: ['offer-injected', 'offer-peer'], selectedOfferRef: 'offer-injected',
|
|
304
|
+
};
|
|
305
|
+
const initial = rt.startTask({ ...turn(taskId), workspace: baseline });
|
|
306
|
+
const workspaceInjectionCases = [
|
|
307
|
+
{
|
|
308
|
+
name: 'hotel-only',
|
|
309
|
+
workspace: { ...baseline, visibleHotels: [{ ...baseline.visibleHotels[0], name: 'Injected Hotel Renamed' }] },
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'loadedOffer-only',
|
|
313
|
+
workspace: { ...baseline, loadedOffers: [baseline.loadedOffers[0], loadedOffer('offer-peer', 'hotel-injected', 'offer-peer:v2')] },
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: 'selectedOfferRef-only',
|
|
317
|
+
workspace: { ...baseline, selectedOfferRef: 'offer-peer' },
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'shortlistedOfferRefs-only',
|
|
321
|
+
workspace: { ...baseline, shortlistedOfferRefs: ['offer-injected'] },
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: 'verifiedOffer-only',
|
|
325
|
+
workspace: { ...baseline, verifiedOffer: verifiedCapability('offer-injected', 'offer-injected:v1', '2026-09-01T10:15:00.000Z') },
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
for (const [index, testCase] of workspaceInjectionCases.entries()) {
|
|
329
|
+
const beforeRejectedTurn = ledger.countEvents();
|
|
330
|
+
assert.throws(() => rt.startTask({ ...turn(taskId), turnId: `same-revision-injected-${index}`, workspace: testCase.workspace, request: { text: `book the ${testCase.name} offer` } }), /workspace_mismatch/, `${testCase.name} workspace injection is rejected as a digest mismatch`);
|
|
331
|
+
assert.equal(ledger.countEvents(), beforeRejectedTurn, `${testCase.name} workspace mismatch leaves the ledger unchanged`);
|
|
332
|
+
}
|
|
333
|
+
const injectedCapabilityWorkspace = workspaceInjectionCases.at(-1).workspace;
|
|
334
|
+
const injectedCheckout = { offerRef: 'offer-injected', offerVersionRef: 'offer-injected:v1', verifiedOfferRef: 'verified-offer-injected' };
|
|
335
|
+
const beforeRejectedCheckout = ledger.countEvents();
|
|
336
|
+
assert.throws(() => rt.issueOperation(taskId, { ...action('same-revision-verified-injected-checkout'), kind: 'checkout.prepare', input: injectedCheckout }), (error) => error instanceof Error && error.message === 'offer_version_not_loaded', 'CAS-rejected verified capability cannot authorize an exactly matching checkout.prepare');
|
|
337
|
+
assert.equal(ledger.countEvents(), beforeRejectedCheckout, 'CAS-rejected verified checkout leaves the ledger unchanged');
|
|
338
|
+
const controlRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-verified-authority-control-'));
|
|
339
|
+
const controlLedger = ensureLedger(controlRoot);
|
|
340
|
+
const controlRuntime = new BookingCopilotTaskRuntime(controlLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
341
|
+
const controlTask = controlRuntime.startTask({ ...turn('task-verified-authority-control'), workspace: injectedCapabilityWorkspace });
|
|
342
|
+
const beforeControlCheckout = controlLedger.countEvents();
|
|
343
|
+
const controlEvent = controlRuntime.issueOperation(controlTask.taskId, { ...action('verified-authority-control-checkout'), kind: 'checkout.prepare', input: injectedCheckout });
|
|
344
|
+
assert.equal(controlEvent.action.kind, 'checkout.prepare', 'matching verified capability authorizes checkout.prepare in the positive control');
|
|
345
|
+
assert.equal(controlLedger.countEvents(), beforeControlCheckout + 1, 'positive-control checkout.prepare persists one ledger event');
|
|
346
|
+
controlLedger.close();
|
|
347
|
+
rmSync(controlRoot, { recursive: true, force: true });
|
|
348
|
+
const accepted = rt.startTask({ ...turn(taskId), turnId: 'same-revision-new-turn', workspace: baseline, request: { text: 'find hotels in Dubai again' } });
|
|
349
|
+
assert.equal(accepted.userTurnCount, initial.userTurnCount + 1, 'same snapshot with a new turn identity and text remains accepted');
|
|
350
|
+
const replayWorkspace0 = { ...workspace(0), visibleHotels: [{ hotelRef: 'replay-hotel', name: 'Replay Hotel', factRefs: [] }], loadedOffers: [loadedOffer('replay-offer', 'replay-hotel')] };
|
|
351
|
+
const replayRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-pending-replay-order-'));
|
|
352
|
+
const replayLedger = ensureLedger(replayRoot);
|
|
353
|
+
const replayRuntime = new BookingCopilotTaskRuntime(replayLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
354
|
+
const replayTask = replayRuntime.startTask({ ...turn('task-pending-replay-order'), workspace: replayWorkspace0 });
|
|
355
|
+
const replayAction = replayRuntime.issueOperation(replayTask.taskId, { ...action('pending-replay-check'), kind: 'offer.check', input: { offerRef: 'replay-offer', offerVersionRef: 'replay-offer:v1' } });
|
|
356
|
+
const beforeReplay = replayLedger.countEvents();
|
|
357
|
+
const replayed = replayRuntime.startTask({ ...turn(replayTask.taskId), workspace: replayWorkspace0 });
|
|
358
|
+
assert.equal(replayed.lastTurnId, replayTask.lastTurnId, 'same original user turn replays the existing pending task');
|
|
359
|
+
assert.equal(replayLedger.countEvents(), beforeReplay, 'same original user turn replay leaves the ledger unchanged');
|
|
360
|
+
assert.throws(() => replayRuntime.startTask({ ...turn(replayTask.taskId), turnId: 'pending-replay-new-turn', workspace: { ...replayWorkspace0, selectedOfferRef: 'replay-offer' }, request: { text: 'new turn while receipt is pending' } }), /receipt_required/, 'new turn keeps receipt_required priority over workspace mismatch');
|
|
361
|
+
assert.equal(replayRuntime.resumeTask(replayTask.taskId)?.pendingAction?.actionId, replayAction.action.actionId);
|
|
362
|
+
replayLedger.close();
|
|
363
|
+
rmSync(replayRoot, { recursive: true, force: true });
|
|
364
|
+
const receiptTaskId = 'task-same-revision-receipt';
|
|
365
|
+
const receiptWorkspace0 = { ...workspace(0), visibleHotels: [{ hotelRef: 'receipt-hotel', name: 'Receipt Hotel', factRefs: [] }], loadedOffers: [loadedOffer('receipt-offer', 'receipt-hotel')] };
|
|
366
|
+
rt.startTask({ ...turn(receiptTaskId), workspace: receiptWorkspace0 });
|
|
367
|
+
rt.startTask({ ...turn(receiptTaskId), turnId: 'receipt-same-snapshot-new-turn', workspace: receiptWorkspace0, request: { text: 'check this offer' } });
|
|
368
|
+
rt.issueOperation(receiptTaskId, { ...action('same-revision-check'), kind: 'offer.check', input: { offerRef: 'receipt-offer', offerVersionRef: 'receipt-offer:v1' } });
|
|
369
|
+
const receiptWorkspace = { ...receiptWorkspace0, revision: 1 };
|
|
370
|
+
const receipt = { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'same-revision-check', contextRef: 'ctx-v2', status: 'unavailable', revision: 1, observation: availabilityObservation('receipt-offer'), resultContract: { outcome: 'empty', hardCriteriaMet: false, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } };
|
|
371
|
+
const afterReceipt = rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: receiptTaskId, workspace: receiptWorkspace, receipt });
|
|
372
|
+
assert.equal(afterReceipt.revision, 1, 'a normal receipt advances the durable revision');
|
|
373
|
+
assert.doesNotThrow(() => rt.startTask({ ...turn(receiptTaskId, 1), turnId: 'post-receipt-new-turn', workspace: receiptWorkspace, request: { text: 'continue after the receipt' } }), 'same snapshot after a normal revision advance remains accepted');
|
|
374
|
+
ledger.close();
|
|
375
|
+
rmSync(root, { recursive: true, force: true });
|
|
376
|
+
}
|
|
377
|
+
const directIngressRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-missing-task-'));
|
|
378
|
+
const directIngressLedger = ensureLedger(directIngressRoot);
|
|
379
|
+
const directIngressRuntime = new BookingCopilotTaskRuntime(directIngressLedger);
|
|
380
|
+
const directIngressBefore = directIngressLedger.countEvents();
|
|
381
|
+
let directIngressOriginalError;
|
|
382
|
+
try {
|
|
383
|
+
assert.throws(() => directIngressRuntime.startTask({
|
|
384
|
+
schemaVersion: 'booking.surface', kind: 'user.turn.ingress', requestKey: 'ingress-no-task', surfaceHint: 'tenant',
|
|
385
|
+
workspace: { schemaVersion: 'booking.surface', revision: 0, locale: 'en-US', currency: 'AED', searchDraft: {}, results: { status: 'idle' }, visibleHotels: [], loadedOffers: [], shortlistedOfferRefs: [] },
|
|
386
|
+
request: { text: 'find hotels in Dubai' },
|
|
387
|
+
}), /ingress_binding_required/);
|
|
388
|
+
assert.equal(directIngressLedger.countEvents(), directIngressBefore, 'browser ingress has no direct runtime ledger side effects');
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
directIngressOriginalError = error;
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
try {
|
|
395
|
+
directIngressLedger.close();
|
|
396
|
+
}
|
|
397
|
+
catch (closeError) {
|
|
398
|
+
try {
|
|
399
|
+
rmSync(directIngressRoot, { recursive: true, force: true });
|
|
400
|
+
}
|
|
401
|
+
catch (rmError) {
|
|
402
|
+
throw new AggregateError([closeError, rmError, directIngressOriginalError].filter((e) => e !== undefined), 'directIngress fixture teardown failed');
|
|
403
|
+
}
|
|
404
|
+
throw new AggregateError([closeError, directIngressOriginalError].filter((e) => e !== undefined), 'directIngress fixture teardown failed');
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
rmSync(directIngressRoot, { recursive: true, force: true });
|
|
408
|
+
}
|
|
409
|
+
catch (rmError) {
|
|
410
|
+
throw new AggregateError([rmError, directIngressOriginalError].filter((e) => e !== undefined), 'directIngress fixture teardown failed');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (directIngressOriginalError !== undefined)
|
|
414
|
+
throw directIngressOriginalError;
|
|
415
|
+
const selectedIngressRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-selected-ingress-'));
|
|
416
|
+
const selectedIngressLedger = ensureLedger(selectedIngressRoot);
|
|
417
|
+
const selectedIngressRuntime = new BookingCopilotTaskRuntime(selectedIngressLedger, { contextRefFactory: () => 'ctx-selected-ingress' });
|
|
418
|
+
const selectedIngress = {
|
|
419
|
+
schemaVersion: 'booking.surface', kind: 'user.turn.ingress', requestKey: 'selected-ingress-request', taskHandle: 'opaque-task-handle', surfaceHint: 'tenant',
|
|
420
|
+
workspace: { schemaVersion: 'booking.surface', revision: 0, locale: 'en-US', currency: 'AED', searchDraft: {}, results: { status: 'idle' }, visibleHotels: [{ hotelRef: 'hotel-selected', name: 'Selected Hotel', factRefs: [] }], loadedOffers: [loadedOffer('offer-selected', 'hotel-selected')], focusedHotelRef: 'hotel-selected', shortlistedOfferRefs: [], selectedOfferRef: 'offer-selected' },
|
|
421
|
+
request: { text: 'is this selected room still bookable?' },
|
|
422
|
+
};
|
|
423
|
+
const selectedIngressTask = selectedIngressRuntime.startTask(turn('task-selected-ingress'));
|
|
424
|
+
const selectedPlannerTurn = selectedIngressRuntime.bindIngressTurn(selectedIngress, selectedIngressTask, 'turn-selected-ingress');
|
|
425
|
+
assert.equal(selectedPlannerTurn.workspace.focusedHotelRef, 'hotel-selected');
|
|
426
|
+
assert.equal(selectedPlannerTurn.workspace.selectedOfferRef, 'offer-selected');
|
|
427
|
+
assert.equal(selectedPlannerTurn.workspace.verifiedOffer, undefined, 'initial ingress cannot assert verified availability authority');
|
|
428
|
+
const selectedApproval = {
|
|
429
|
+
taskId: selectedIngressTask.taskId, contextRef: selectedIngressTask.contextRef,
|
|
430
|
+
sourceTurnId: 'turn-source-ingress', presentationRequestKey: 'approval:task-selected-ingress:turn-source-ingress:action-source-ingress',
|
|
431
|
+
optionDigest: 'a'.repeat(64), approvalId: 'approval-selected-ingress', deliveryNonce: 'nonce-selected-ingress',
|
|
432
|
+
blockerId: 'blocker-selected-ingress', sourceActionId: 'action-source-ingress', sourceReceiptDigest: 'b'.repeat(64),
|
|
433
|
+
scope: 'offer', code: 'criterion_must_not_met', criterionPath: 'offers.totalPriceMax', valueDigest: 'c'.repeat(64),
|
|
434
|
+
from: 'must', to: 'prefer', approved: true,
|
|
435
|
+
};
|
|
436
|
+
const approvedSelectedIngress = { ...selectedIngress, request: { text: selectedIngress.request.text, approval: selectedApproval } };
|
|
437
|
+
const approvedSelectedPlannerTurn = selectedIngressRuntime.bindIngressTurn(approvedSelectedIngress, selectedIngressTask, 'turn-approved-selected-ingress');
|
|
438
|
+
assert.deepEqual(approvedSelectedPlannerTurn.request.approval, selectedApproval, 'runtime ingress binding preserves the exact canonical approval');
|
|
439
|
+
assert.notEqual(approvedSelectedPlannerTurn.request.approval, selectedApproval, 'runtime ingress binding clones approval data before planner admission');
|
|
440
|
+
assert.throws(() => selectedIngressRuntime.bindIngressTurn({ ...approvedSelectedIngress, request: { ...approvedSelectedIngress.request, approval: { ...selectedApproval, taskId: 'browser-forged-task' } } }, selectedIngressTask, 'turn-forged-selected-ingress'), /invalid_ingress_approval_authority/);
|
|
441
|
+
assert.throws(() => selectedIngressRuntime.bindIngressTurn({ ...approvedSelectedIngress, request: { ...approvedSelectedIngress.request, approval: { ...selectedApproval, contextRef: 'browser-forged-context' } } }, selectedIngressTask, 'turn-forged-context-selected-ingress'), /invalid_ingress_approval_authority/);
|
|
442
|
+
selectedIngressLedger.close();
|
|
443
|
+
rmSync(selectedIngressRoot, { recursive: true, force: true });
|
|
444
|
+
const duplicateOfferWorkspace = { ...workspace(0), visibleHotels: [{ hotelRef: 'hotel-dup-a', name: 'Hotel Dup A', factRefs: [] }, { hotelRef: 'hotel-dup-b', name: 'Hotel Dup B', factRefs: [] }], loadedOffers: [loadedOffer('duplicate-offer-ref', 'hotel-dup-a'), loadedOffer('duplicate-offer-ref', 'hotel-dup-b')] };
|
|
445
|
+
const duplicateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-duplicate-offer-'));
|
|
446
|
+
const duplicateLedger = ensureLedger(duplicateRoot);
|
|
447
|
+
const duplicateRuntime = new BookingCopilotTaskRuntime(duplicateLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
448
|
+
assert.equal(validateBookingSurface({ ...turn('validator-duplicate-logical'), workspace: duplicateOfferWorkspace }).ok, false, 'published validator rejects duplicate logical offer refs');
|
|
449
|
+
const duplicateVersionWorkspace = { ...workspace(0), visibleHotels: [{ hotelRef: 'hotel-dup-a', name: 'Hotel Dup A', factRefs: [] }, { hotelRef: 'hotel-dup-b', name: 'Hotel Dup B', factRefs: [] }], loadedOffers: [loadedOffer('offer-dup-a', 'hotel-dup-a', 'shared-offer:v1'), loadedOffer('offer-dup-b', 'hotel-dup-b', 'shared-offer:v1')] };
|
|
450
|
+
assert.equal(validateBookingSurface({ ...turn('validator-duplicate-version'), workspace: duplicateVersionWorkspace }).ok, false, 'published validator rejects duplicate offer version refs');
|
|
451
|
+
const verifiedVersionMismatchWorkspace = { ...workspace(0), visibleHotels: [{ hotelRef: 'hotel-verified', name: 'Verified Hotel', factRefs: [] }], loadedOffers: [loadedOffer('offer-verified', 'hotel-verified', 'offer-verified:v1')], verifiedOffer: verifiedCapability('offer-verified', 'offer-verified:v2') };
|
|
452
|
+
assert.equal(validateBookingSurface({ ...turn('validator-verified-version-mismatch'), workspace: verifiedVersionMismatchWorkspace }).ok, false, 'standalone workspace validation rejects a verified capability for an unloaded version');
|
|
453
|
+
assert.equal(validateBookingSurface({ ...turn('validator-verified-logical-mismatch'), workspace: { ...verifiedVersionMismatchWorkspace, verifiedOffer: verifiedCapability('other-offer', 'other-offer:v1') } }).ok, false, 'standalone workspace validation rejects a verified capability for another logical offer');
|
|
454
|
+
assert.throws(() => duplicateRuntime.startTask({ ...turn('task-duplicate-offer'), workspace: duplicateOfferWorkspace }), /invalid_planner_turn|availability_duplicate_offer_ref/);
|
|
455
|
+
assert.equal(duplicateLedger.countEvents(), 0, 'duplicate loaded OfferRef is rejected at admission before ledger writes');
|
|
456
|
+
const duplicateContinuationRuntime = new BookingCopilotTaskRuntime(duplicateLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
457
|
+
const duplicateContinuationTask = duplicateContinuationRuntime.startTask({ ...turn('task-duplicate-continuation'), workspace: { ...workspace(0), visibleHotels: [{ hotelRef: 'hotel-dup-a', name: 'Hotel Dup A', factRefs: [] }], loadedOffers: [loadedOffer('unique-offer-ref', 'hotel-dup-a')] } });
|
|
458
|
+
duplicateContinuationRuntime.issueOperation(duplicateContinuationTask.taskId, { ...action('duplicate-continuation-check'), kind: 'offer.check', input: { offerRef: 'unique-offer-ref', offerVersionRef: 'unique-offer-ref:v1' } });
|
|
459
|
+
assert.throws(() => duplicateContinuationRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: duplicateContinuationTask.taskId, workspace: { ...duplicateOfferWorkspace, revision: 1 }, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'duplicate-continuation-check', contextRef: 'ctx-v2', status: 'unavailable', revision: 1, observation: availabilityObservation('unique-offer-ref'), resultContract: { outcome: 'empty', hardCriteriaMet: false, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } }), /invalid_receipt_continuation|availability_duplicate_offer_ref/);
|
|
460
|
+
duplicateContinuationRuntime.appendTurn('task-duplicate-continuation', 'ctx-v2', 'tampered', 'tampered', 'tampered', 2, 'duplicate-replay-turn', duplicateOfferWorkspace);
|
|
461
|
+
assert.throws(() => duplicateContinuationRuntime.resumeTask('task-duplicate-continuation'), /ledger_corrupt:task-duplicate-continuation:turn_workspace/, 'duplicate loaded OfferRef is fail-closed during ledger replay');
|
|
462
|
+
duplicateLedger.close();
|
|
463
|
+
rmSync(duplicateRoot, { recursive: true, force: true });
|
|
464
|
+
// A receipt for offer A is not authority to mutate the selection to offer B.
|
|
465
|
+
// The receipt target and the resulting workspace selection must agree on the
|
|
466
|
+
// same logical/versioned offer pair.
|
|
467
|
+
const selectRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-select-receipt-binding-'));
|
|
468
|
+
const selectLedger = ensureLedger(selectRoot);
|
|
469
|
+
const selectRuntime = new BookingCopilotTaskRuntime(selectLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
470
|
+
const selectWorkspace = {
|
|
471
|
+
...workspace(0),
|
|
472
|
+
visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }, { hotelRef: 'hotel-b', name: 'Hotel B', factRefs: [] }],
|
|
473
|
+
loadedOffers: [loadedOffer('offer-a', 'hotel-a'), loadedOffer('offer-b', 'hotel-b')],
|
|
474
|
+
shortlistedOfferRefs: ['offer-a', 'offer-b'],
|
|
475
|
+
};
|
|
476
|
+
const selectTask = selectRuntime.startTask({ ...turn('task-select-receipt-binding'), workspace: selectWorkspace });
|
|
477
|
+
selectRuntime.issueOperation(selectTask.taskId, { ...action('select-a'), kind: 'offer.select', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } });
|
|
478
|
+
assert.throws(() => selectRuntime.continueWithReceipt({
|
|
479
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: selectTask.taskId,
|
|
480
|
+
workspace: { ...selectWorkspace, revision: 1, selectedOfferRef: 'offer-b' },
|
|
481
|
+
receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'select-a', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.selection', offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } },
|
|
482
|
+
}), /workspace_mismatch|receipt_transition/, 'offer.select receipt A cannot select offer B');
|
|
483
|
+
selectLedger.close();
|
|
484
|
+
rmSync(selectRoot, { recursive: true, force: true });
|
|
485
|
+
// Checkout is admitted only for the exact, unexpired verified capability tuple:
|
|
486
|
+
// logical offer, version and opaque verification ref all bind together.
|
|
487
|
+
const checkoutWorkspace = (revision, capability) => ({
|
|
488
|
+
...workspace(revision),
|
|
489
|
+
visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }, { hotelRef: 'hotel-b', name: 'Hotel B', factRefs: [] }],
|
|
490
|
+
loadedOffers: [loadedOffer('offer-a', 'hotel-a'), loadedOffer('offer-b', 'hotel-b')],
|
|
491
|
+
selectedOfferRef: 'offer-a',
|
|
492
|
+
verifiedOffer: capability,
|
|
493
|
+
});
|
|
494
|
+
const checkoutReceipt = (actionId, offerRef, offerVersionRef, verifiedOfferRef) => ({
|
|
495
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId, contextRef: 'ctx-v2', status: 'applied', revision: 1,
|
|
496
|
+
observation: { kind: 'checkout.handoff', offerRef, offerVersionRef, verifiedOfferRef, handoffRef: `${offerRef}-checkout` },
|
|
497
|
+
resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
|
|
498
|
+
});
|
|
499
|
+
{
|
|
500
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-'));
|
|
501
|
+
const ledger = ensureLedger(root);
|
|
502
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
503
|
+
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
504
|
+
const t = rt.startTask({ ...turn('task-checkout-capability-exact'), workspace: checkoutWorkspace(0, capability) });
|
|
505
|
+
const checkout = { ...action('checkout-exact'), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef } };
|
|
506
|
+
rt.issueOperation(t.taskId, checkout);
|
|
507
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: checkoutWorkspace(1, capability), receipt: checkoutReceipt('checkout-exact', 'offer-a', 'offer-a:v1', capability.verifiedOfferRef) });
|
|
508
|
+
assert.equal(rt.resumeTask(t.taskId)?.lastReceipt?.observation.kind, 'checkout.handoff', 'exact unexpired capability reaches checkout handoff');
|
|
509
|
+
ledger.close();
|
|
510
|
+
rmSync(root, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
{
|
|
513
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-after-check-'));
|
|
514
|
+
const ledger = ensureLedger(root);
|
|
515
|
+
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
516
|
+
const before = {
|
|
517
|
+
...workspace(0),
|
|
518
|
+
visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }],
|
|
519
|
+
loadedOffers: [loadedOffer('offer-a', 'hotel-a')],
|
|
520
|
+
shortlistedOfferRefs: ['offer-a'],
|
|
521
|
+
selectedOfferRef: 'offer-a',
|
|
522
|
+
};
|
|
523
|
+
const after = { ...before, revision: 1, verifiedOffer: capability };
|
|
524
|
+
const writer = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
525
|
+
const t = writer.startTask({ ...turn('task-checkout-after-check'), workspace: before });
|
|
526
|
+
writer.issueOperation(t.taskId, { ...action('check-before-checkout'), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } }, checkoutPreparedIntent);
|
|
527
|
+
writer.continueWithReceipt({
|
|
528
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: after,
|
|
529
|
+
receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'check-before-checkout', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-a', checkedOfferVersionRef: 'offer-a:v1', currentOfferVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } },
|
|
530
|
+
});
|
|
531
|
+
const confirmed = writer.resumeTask(t.taskId);
|
|
532
|
+
assert.equal(confirmed?.availability.terminal?.code, 'availability_confirmed');
|
|
533
|
+
assert.equal(confirmed?.phase, 'planning', 'confirmed availability keeps the same task plannable for checkout');
|
|
534
|
+
assert.throws(() => writer.terminalDecisionBatch(t.taskId, 'receipt:check-before-checkout:after-check'), /task_not_terminal/, 'confirmed availability does not create a runtime-owned terminal batch');
|
|
535
|
+
writer['ledger'].close();
|
|
536
|
+
let checkoutNow = '2026-09-01T10:00:00.000Z';
|
|
537
|
+
const reader = new BookingCopilotTaskRuntime(ensureLedger(root), { contextRefFactory: () => 'ctx-v2', now: () => checkoutNow });
|
|
538
|
+
assert.equal(reader.resumeTask(t.taskId)?.phase, 'planning', 'confirmed availability remains plannable after ledger restart');
|
|
539
|
+
const beforeCheckoutRows = reader['ledger'].countEvents();
|
|
540
|
+
const swappedCapability = verifiedCapability('offer-b', 'offer-b:v1', '2026-09-01T10:30:00.000Z');
|
|
541
|
+
const swappedWorkspace = { ...after, visibleHotels: [{ hotelRef: 'hotel-b', name: 'Hotel B', factRefs: [] }], loadedOffers: [loadedOffer('offer-b', 'hotel-b')], shortlistedOfferRefs: ['offer-b'], selectedOfferRef: 'offer-b', verifiedOffer: swappedCapability };
|
|
542
|
+
assert.throws(() => reader.startTask({ ...turn(t.taskId, 1), turnId: 'turn-swap-confirmed-offer', workspace: swappedWorkspace, request: { text: 'continue with the substituted offer' } }), /workspace_mismatch/, 'a same-revision turn cannot replace the offer tuple established by CheckAvail');
|
|
543
|
+
assert.throws(() => reader.issueOperation(t.taskId, action('search-after-confirmed-direct', 1)), /availability_operation_incompatible/, 'direct operations cannot bypass the confirmed-state guard');
|
|
544
|
+
assert.throws(() => reader.issueOperation(t.taskId, { ...action('query-after-confirmed-direct', 1), kind: 'offers.query', input: { hotelRefs: ['hotel-a'], criteria: { targetCount: 1 } } }), /availability_operation_incompatible/, 'confirmed availability cannot query more offers');
|
|
545
|
+
assert.throws(() => reader.issueOperation(t.taskId, { ...action('recheck-after-confirmed-direct', 1), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } }), /availability_operation_incompatible/, 'confirmed availability cannot issue a second check');
|
|
546
|
+
assert.throws(() => reader.issueOperation(t.taskId, { ...action('observe-after-confirmed-direct', 1), kind: 'order.observe', input: { orderRef: 'order-a' } }), /action_intent_mismatch/, 'the durable checkout waypoint rejects an order-observe overshoot before availability can be bypassed');
|
|
547
|
+
assert.throws(() => reader.applyDecisionBatch(t.taskId, 'checkout-after-check-stale-batch', [{ kind: 'operation', action: { ...action('checkout-after-check-stale', 0), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef } }, intent: checkoutPreparedIntent }]), /stale_revision/, 'confirmed availability does not relax the semantic revision binding');
|
|
548
|
+
assert.throws(() => reader.applyDecisionBatch(t.taskId, 'checkout-after-check-wrong-offer-batch', [{ kind: 'operation', action: { ...action('checkout-after-check-wrong-offer', 1), kind: 'checkout.prepare', input: { offerRef: 'offer-b', offerVersionRef: 'offer-b:v1', verifiedOfferRef: capability.verifiedOfferRef } }, intent: checkoutPreparedIntent }]), /availability_operation_incompatible/, 'confirmed availability does not relax the checked offer binding');
|
|
549
|
+
assert.throws(() => reader.applyDecisionBatch(t.taskId, 'checkout-after-check-wrong-ref-batch', [{ kind: 'operation', action: { ...action('checkout-after-check-wrong-ref', 1), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: 'verified-other' } }, intent: checkoutPreparedIntent }]), /availability_operation_incompatible/, 'confirmed availability does not relax the verified ref binding');
|
|
550
|
+
assert.throws(() => reader.applyDecisionBatch(t.taskId, 'checkout-after-check-wrong-version-batch', [{ kind: 'operation', action: { ...action('checkout-after-check-wrong-version', 1), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v2', verifiedOfferRef: capability.verifiedOfferRef } }, intent: checkoutPreparedIntent }]), /availability_operation_incompatible/, 'confirmed availability does not relax the checked version binding');
|
|
551
|
+
assert.throws(() => reader.applyDecisionBatch(t.taskId, 'checkout-after-check-search-batch', [{ kind: 'operation', action: action('search-after-confirmed', 1), intent: checkoutPreparedIntent }]), /availability_operation_incompatible/, 'confirmed availability does not allow unrelated read actions');
|
|
552
|
+
assert.throws(() => reader.applyDecisionBatch(t.taskId, 'checkout-after-check-explanation-only-batch', [{ kind: 'explanation', explanation: { text: 'extra', factRefs: [] } }]), /availability_operation_required/, 'confirmed availability cannot emit a non-final explanation instead of checkout or typed finality');
|
|
553
|
+
assert.throws(() => reader.applyDecisionBatch(t.taskId, 'checkout-after-check-mixed-batch', [{ kind: 'operation', action: { ...action('checkout-after-check-mixed', 1), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef } }, intent: checkoutPreparedIntent }, { kind: 'explanation', explanation: { text: 'extra', factRefs: [] } }]), /availability_operation_required/, 'confirmed availability admits only a single checkout.prepare operation batch');
|
|
554
|
+
checkoutNow = '2026-09-01T10:31:00.000Z';
|
|
555
|
+
assert.throws(() => reader.applyDecisionBatch(t.taskId, 'checkout-after-check-expired-batch', [{ kind: 'operation', action: { ...action('checkout-after-check-expired', 1), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef } }, intent: checkoutPreparedIntent }]), /availability_operation_incompatible/, 'confirmed availability cannot use an expired verified capability');
|
|
556
|
+
checkoutNow = '2026-09-01T10:00:00.000Z';
|
|
557
|
+
assert.equal(reader['ledger'].countEvents(), beforeCheckoutRows, 'rejected confirmed-state turns and decisions do not append ledger rows');
|
|
558
|
+
reader.applyDecisionBatch(t.taskId, 'checkout-after-check-batch', [{ kind: 'operation', action: { ...action('checkout-after-check', 1), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef } }, intent: checkoutPreparedIntent }]);
|
|
559
|
+
assert.equal(reader.resumeTask(t.taskId)?.pendingAction?.kind, 'checkout.prepare', 'checkout.prepare binds to the same checked offer/version/verified ref after confirmation');
|
|
560
|
+
const checkoutHandoffReceipt = checkoutReceipt('checkout-after-check', 'offer-a', 'offer-a:v1', capability.verifiedOfferRef);
|
|
561
|
+
const checkoutTerminalState = reader.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: { ...after, revision: 2 }, receipt: { ...checkoutHandoffReceipt, revision: 2 } });
|
|
562
|
+
assert.equal(checkoutTerminalState.phase, 'terminal', 'applied checkout handoff closes the task');
|
|
563
|
+
const checkoutTerminalKey = `receipt:checkout-after-check:${reader.receiptDigest({ ...checkoutHandoffReceipt, revision: 2 })}`;
|
|
564
|
+
const checkoutTerminalBatch = reader.terminalDecisionBatch(t.taskId, checkoutTerminalKey);
|
|
565
|
+
assert.deepEqual(checkoutTerminalBatch.map((event) => event.kind), ['terminal'], 'checkout handoff has a once-only terminal replay batch');
|
|
566
|
+
assert.equal(checkoutTerminalBatch[0]?.kind === 'terminal' ? checkoutTerminalBatch[0].terminal.summary : '', 'checkout_handoff_prepared');
|
|
567
|
+
const checkoutTerminalRows = reader['ledger'].countEvents();
|
|
568
|
+
reader.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: { ...after, revision: 2 }, receipt: { ...checkoutHandoffReceipt, revision: 2 } });
|
|
569
|
+
assert.equal(reader['ledger'].countEvents(), checkoutTerminalRows, 'exact checkout receipt replay does not append a second terminal');
|
|
570
|
+
assert.throws(() => reader.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: { ...after, revision: 2 }, receipt: { ...checkoutHandoffReceipt, status: 'partial', revision: 2, resultContract: { ...checkoutHandoffReceipt.resultContract, outcome: 'partial', hardCriteriaMet: false, gapCodes: ['surface_adapter_failed'] } } }), /receipt_conflict/, 'a conflicting checkout receipt cannot overwrite the terminal result');
|
|
571
|
+
assert.throws(() => reader.issueOperation(t.taskId, { ...action('checkout-after-terminal', 2), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef } }), /task_terminal/, 'checkout handoff permits no second operation');
|
|
572
|
+
reader['ledger'].close();
|
|
573
|
+
const terminalReader = new BookingCopilotTaskRuntime(ensureLedger(root), { now: () => checkoutNow });
|
|
574
|
+
assert.equal(terminalReader.resumeTask(t.taskId)?.phase, 'terminal', 'checkout handoff terminal survives restart');
|
|
575
|
+
assert.deepEqual(terminalReader.readDecisionBatch(t.taskId, checkoutTerminalKey), checkoutTerminalBatch, 'checkout handoff terminal batch survives restart byte-for-byte');
|
|
576
|
+
terminalReader['ledger'].close();
|
|
577
|
+
rmSync(root, { recursive: true, force: true });
|
|
578
|
+
}
|
|
579
|
+
{
|
|
580
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-not-prepared-'));
|
|
581
|
+
const ledger = ensureLedger(root);
|
|
582
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
583
|
+
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
584
|
+
const before = { ...workspace(0), visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }], loadedOffers: [loadedOffer('offer-a', 'hotel-a')], shortlistedOfferRefs: ['offer-a'], selectedOfferRef: 'offer-a' };
|
|
585
|
+
const checked = { ...before, revision: 1, verifiedOffer: capability };
|
|
586
|
+
const beginCheckout = (taskId, checkActionId, checkoutActionId) => {
|
|
587
|
+
const task = rt.startTask({ ...turn(taskId), workspace: before });
|
|
588
|
+
rt.issueOperation(task.taskId, { ...action(checkActionId), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } }, checkoutPreparedIntent);
|
|
589
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId, workspace: checked, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: checkActionId, contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-a', checkedOfferVersionRef: 'offer-a:v1', currentOfferVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } });
|
|
590
|
+
rt.applyDecisionBatch(taskId, `batch-${checkoutActionId}`, [{ kind: 'operation', action: { ...action(checkoutActionId, 1), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef } }, intent: checkoutPreparedIntent }]);
|
|
591
|
+
};
|
|
592
|
+
beginCheckout('task-checkout-incomplete', 'check-before-incomplete', 'checkout-incomplete');
|
|
593
|
+
const incompleteReceipt = {
|
|
594
|
+
...checkoutReceipt('checkout-incomplete', 'offer-a', 'offer-a:v1', capability.verifiedOfferRef), revision: 2,
|
|
595
|
+
resultContract: { outcome: 'partial', hardCriteriaMet: false, factRefs: [], gapCodes: ['surface_adapter_failed'], blockers: [], relaxationsApplied: [] },
|
|
596
|
+
};
|
|
597
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: 'task-checkout-incomplete', workspace: { ...checked, revision: 2 }, receipt: incompleteReceipt });
|
|
598
|
+
const incompleteKey = `receipt:checkout-incomplete:${rt.receiptDigest(incompleteReceipt)}`;
|
|
599
|
+
const incompleteTerminal = rt.readDecisionBatch('task-checkout-incomplete', incompleteKey);
|
|
600
|
+
assert.equal(incompleteTerminal?.[0]?.kind === 'terminal' ? incompleteTerminal[0].terminal.status : '', 'stopped', 'an incomplete checkout handoff cannot claim success');
|
|
601
|
+
assert.equal(incompleteTerminal?.[0]?.kind === 'terminal' ? incompleteTerminal[0].terminal.summary : '', 'checkout_handoff_not_prepared');
|
|
602
|
+
beginCheckout('task-checkout-failed', 'check-before-failed', 'checkout-failed');
|
|
603
|
+
const failedReceipt = {
|
|
604
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'checkout-failed', contextRef: 'ctx-v2', status: 'failed', revision: 2,
|
|
605
|
+
observation: { kind: 'gap', code: 'surface_adapter_failed', factRefs: [] },
|
|
606
|
+
resultContract: { outcome: 'empty', hardCriteriaMet: false, factRefs: [], gapCodes: ['surface_adapter_failed'], blockers: [], relaxationsApplied: [] },
|
|
607
|
+
};
|
|
608
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: 'task-checkout-failed', workspace: { ...checked, revision: 2 }, receipt: failedReceipt });
|
|
609
|
+
const failedKey = `receipt:checkout-failed:${rt.receiptDigest(failedReceipt)}`;
|
|
610
|
+
const failedTerminal = rt.readDecisionBatch('task-checkout-failed', failedKey);
|
|
611
|
+
assert.equal(failedTerminal?.[0]?.kind === 'terminal' ? failedTerminal[0].terminal.status : '', 'stopped', 'a failed checkout handoff terminates without planner retry');
|
|
612
|
+
assert.equal(failedTerminal?.[0]?.kind === 'terminal' ? failedTerminal[0].terminal.summary : '', 'checkout_handoff_not_prepared');
|
|
613
|
+
beginCheckout('task-checkout-atomic', 'check-before-atomic', 'checkout-atomic');
|
|
614
|
+
const atomicReceipt = { ...checkoutReceipt('checkout-atomic', 'offer-a', 'offer-a:v1', capability.verifiedOfferRef), revision: 2 };
|
|
615
|
+
const atomicTurn = { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: 'task-checkout-atomic', workspace: { ...checked, revision: 2 }, receipt: atomicReceipt };
|
|
616
|
+
const beforeAtomicReceiptRows = ledger.countEvents();
|
|
617
|
+
ledger.db.exec("CREATE TRIGGER abort_checkout_terminal_batch BEFORE INSERT ON events WHEN NEW.kind = 'booking.copilot.decision.batch' AND NEW.run_id = 'task-checkout-atomic' BEGIN SELECT RAISE(ABORT, 'crash_before_checkout_terminal_batch'); END");
|
|
618
|
+
assert.throws(() => rt.continueWithReceipt(atomicTurn), /crash_before_checkout_terminal_batch|SQLITE_CONSTRAINT/, 'a crash before the checkout terminal batch aborts the whole receipt transaction');
|
|
619
|
+
assert.equal(ledger.countEvents(), beforeAtomicReceiptRows, 'failed checkout terminal transaction leaves no receipt or terminal event tail');
|
|
620
|
+
assert.equal(rt.resumeTask('task-checkout-atomic')?.pendingAction?.kind, 'checkout.prepare', 'checkout action remains pending after the simulated crash');
|
|
621
|
+
ledger.db.exec('DROP TRIGGER abort_checkout_terminal_batch');
|
|
622
|
+
const atomicTerminal = rt.continueWithReceipt(atomicTurn);
|
|
623
|
+
assert.equal(atomicTerminal.phase, 'terminal', 'retry after the simulated crash reaches one terminal state');
|
|
624
|
+
assert.equal(ledger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE run_id = ? AND kind = 'booking.copilot.receipt.observed'").get('task-checkout-atomic').n, 2, 'task retains one check receipt and one checkout receipt');
|
|
625
|
+
assert.equal(ledger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE run_id = ? AND kind = 'booking.copilot.event.emitted' AND json_extract(payload, '$.eventKind') = 'terminal'").get('task-checkout-atomic').n, 1, 'retry records exactly one checkout terminal event');
|
|
626
|
+
assert.equal(ledger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE run_id = ? AND kind = 'booking.copilot.decision.batch' AND json_extract(payload, '$.requestKey') LIKE 'receipt:checkout-atomic:%'").get('task-checkout-atomic').n, 1, 'retry records exactly one checkout terminal decision batch');
|
|
627
|
+
ledger.close();
|
|
628
|
+
rmSync(root, { recursive: true, force: true });
|
|
629
|
+
}
|
|
630
|
+
{
|
|
631
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-confirmed-check-only-'));
|
|
632
|
+
const ledger = ensureLedger(root);
|
|
633
|
+
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
634
|
+
const before = { ...workspace(0), visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }], loadedOffers: [loadedOffer('offer-a', 'hotel-a')], shortlistedOfferRefs: ['offer-a'], selectedOfferRef: 'offer-a' };
|
|
635
|
+
const after = { ...before, revision: 1, verifiedOffer: capability };
|
|
636
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
637
|
+
const t = rt.startTask({ ...turn('task-confirmed-check-only'), workspace: before });
|
|
638
|
+
rt.issueOperation(t.taskId, { ...action('check-only-before-terminal'), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } });
|
|
639
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: after, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'check-only-before-terminal', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-a', checkedOfferVersionRef: 'offer-a:v1', currentOfferVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } });
|
|
640
|
+
const events = rt.applyDecisionBatch(t.taskId, 'check-only-completed-terminal', [{ kind: 'explanation', explanation: { text: 'The selected offer is still available.', factRefs: [] } }, { kind: 'terminal', terminal: { status: 'completed', summary: 'availability_confirmed', factRefs: [] } }]);
|
|
641
|
+
assert.deepEqual(events.map((event) => event.kind), ['status', 'explanation', 'terminal'], 'confirmed availability may close a check-only intent with explanation and completed terminal');
|
|
642
|
+
assert.equal(rt.resumeTask(t.taskId)?.phase, 'terminal');
|
|
643
|
+
const stoppedTask = rt.startTask({ ...turn('task-confirmed-check-only-stopped'), workspace: before });
|
|
644
|
+
rt.issueOperation(stoppedTask.taskId, { ...action('check-only-before-stopped', 0), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } });
|
|
645
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: stoppedTask.taskId, workspace: after, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'check-only-before-stopped', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-a', checkedOfferVersionRef: 'offer-a:v1', currentOfferVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } });
|
|
646
|
+
assert.deepEqual(rt.applyDecisionBatch(stoppedTask.taskId, 'check-only-stopped-terminal', [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'user_stopped_after_check', factRefs: [] } }]).map((event) => event.kind), ['status', 'terminal'], 'confirmed availability may close a check-only intent with a typed stopped terminal');
|
|
647
|
+
const errorTask = rt.startTask({ ...turn('task-confirmed-check-only-error'), workspace: before });
|
|
648
|
+
rt.issueOperation(errorTask.taskId, { ...action('check-only-before-error', 0), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } });
|
|
649
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: errorTask.taskId, workspace: after, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'check-only-before-error', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-a', checkedOfferVersionRef: 'offer-a:v1', currentOfferVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } });
|
|
650
|
+
assert.deepEqual(rt.applyDecisionBatch(errorTask.taskId, 'check-only-error-final', [{ kind: 'error', error: { code: 'PLANNER_FAILED', message: 'Planner stopped after check.', retryable: false } }]).map((event) => event.kind), ['status', 'error'], 'confirmed availability may close with a typed error finality');
|
|
651
|
+
const historicalRows = ledger.countEvents();
|
|
652
|
+
ledger.close();
|
|
653
|
+
const historicalReader = new BookingCopilotTaskRuntime(ensureLedger(root));
|
|
654
|
+
assert.equal(historicalReader.resumeTask(t.taskId)?.phase, 'terminal', 'a historical explicit terminal after confirmation remains terminal after restart');
|
|
655
|
+
assert.equal(historicalReader['ledger'].countEvents(), historicalRows);
|
|
656
|
+
historicalReader['ledger'].close();
|
|
657
|
+
rmSync(root, { recursive: true, force: true });
|
|
658
|
+
}
|
|
659
|
+
{
|
|
660
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-confirmed-at-operation-limit-'));
|
|
661
|
+
const ledger = ensureLedger(root);
|
|
662
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
663
|
+
const capability = verifiedCapability('offer-limit', 'offer-limit:v1', '2026-09-01T10:30:00.000Z');
|
|
664
|
+
const before = { ...workspace(0), visibleHotels: [{ hotelRef: 'hotel-limit', name: 'Limit Hotel', factRefs: [] }], loadedOffers: [loadedOffer('offer-limit', 'hotel-limit')], shortlistedOfferRefs: ['offer-limit'], selectedOfferRef: 'offer-limit' };
|
|
665
|
+
const taskId = 'task-confirmed-at-operation-limit';
|
|
666
|
+
const focusedBefore = { ...before, focusedHotelRef: 'hotel-limit' };
|
|
667
|
+
const limitIntent = { schemaVersion: 'booking.intent.v1', target: 'offer.verified' };
|
|
668
|
+
rt.startTask({ ...turn(taskId), workspace: focusedBefore });
|
|
669
|
+
for (let ordinal = 1; ordinal < BOOKING_COPILOT_MAX_OPERATIONS; ordinal++) {
|
|
670
|
+
const operation = rt.issueOperation(taskId, { ...action(`limit-focus-${ordinal}`), kind: 'hotel.focus', input: { hotelRef: 'hotel-limit' } }, limitIntent);
|
|
671
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId, workspace: focusedBefore, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: operation.action.actionId, contextRef: 'ctx-v2', status: 'applied', revision: 0, observation: { kind: 'hotel.focus', hotelRef: 'hotel-limit' }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } });
|
|
672
|
+
}
|
|
673
|
+
const check = rt.issueOperation(taskId, { ...action('limit-check'), kind: 'offer.check', input: { offerRef: 'offer-limit', offerVersionRef: 'offer-limit:v1' } }, limitIntent);
|
|
674
|
+
assert.equal(rt.resumeTask(taskId)?.operationCount, BOOKING_COPILOT_MAX_OPERATIONS, 'the successful check is the actual twentieth operation');
|
|
675
|
+
const checkReceipt = { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: check.action.actionId, contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-limit', checkedOfferVersionRef: 'offer-limit:v1', currentOfferVersionRef: 'offer-limit:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } };
|
|
676
|
+
const terminal = rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId, workspace: { ...focusedBefore, revision: 1, verifiedOffer: capability }, receipt: checkReceipt });
|
|
677
|
+
assert.equal(terminal.availability.terminal?.code, 'availability_confirmed', 'the availability subflow records its conclusive success');
|
|
678
|
+
assert.equal(terminal.phase, 'terminal');
|
|
679
|
+
const limitBatch = rt.readDecisionBatch(taskId, `receipt:${checkReceipt.actionId}:${rt.receiptDigest(checkReceipt)}`);
|
|
680
|
+
assert.equal(limitBatch?.[0]?.kind === 'terminal' ? limitBatch[0].terminal.status : '', 'stopped', 'the hard operation limit wins over a confirmed availability subflow');
|
|
681
|
+
assert.equal(limitBatch?.[0]?.kind === 'terminal' ? limitBatch[0].terminal.summary : '', 'operation_limit_reached');
|
|
682
|
+
assert.equal(limitBatch?.some((event) => event.kind === 'terminal' && event.terminal.summary === 'availability_confirmed'), false, 'operation limit cannot be misreported as task success');
|
|
683
|
+
ledger.close();
|
|
684
|
+
rmSync(root, { recursive: true, force: true });
|
|
685
|
+
}
|
|
686
|
+
{
|
|
687
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-cross-offer-'));
|
|
688
|
+
const ledger = ensureLedger(root);
|
|
689
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
690
|
+
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
691
|
+
const t = rt.startTask({ ...turn('task-checkout-capability-cross-offer'), workspace: checkoutWorkspace(0, capability) });
|
|
692
|
+
assert.throws(() => rt.issueOperation(t.taskId, { ...action('checkout-cross-offer'), kind: 'checkout.prepare', input: { offerRef: 'offer-b', offerVersionRef: 'offer-b:v1', verifiedOfferRef: capability.verifiedOfferRef } }), /verified_offer|offer_capability|offer_version/, 'offer A capability cannot authorize offer B');
|
|
693
|
+
ledger.close();
|
|
694
|
+
rmSync(root, { recursive: true, force: true });
|
|
695
|
+
}
|
|
696
|
+
{
|
|
697
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-expired-'));
|
|
698
|
+
const ledger = ensureLedger(root);
|
|
699
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
700
|
+
const expired = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T09:59:59.000Z');
|
|
701
|
+
const t = rt.startTask({ ...turn('task-checkout-capability-expired'), workspace: checkoutWorkspace(0, expired) });
|
|
702
|
+
assert.throws(() => rt.issueOperation(t.taskId, { ...action('checkout-expired'), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: expired.verifiedOfferRef } }), /verified_offer_expired|offer_capability_expired|capability_expired|offer_version_not_loaded/, 'expired verified capability is rejected');
|
|
703
|
+
ledger.close();
|
|
704
|
+
rmSync(root, { recursive: true, force: true });
|
|
705
|
+
}
|
|
706
|
+
{
|
|
707
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-expired-receipt-write-'));
|
|
708
|
+
const ledger = ensureLedger(root);
|
|
709
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T11:00:00.000Z' });
|
|
710
|
+
const expired = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
711
|
+
const before = checkoutWorkspace(0, expired);
|
|
712
|
+
const after = checkoutWorkspace(1, expired);
|
|
713
|
+
const t = rt.startTask({ ...turn('task-expired-receipt-write'), workspace: before });
|
|
714
|
+
rt.issueOperation(t.taskId, { ...action('expired-receipt-check'), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } });
|
|
715
|
+
const receipt = { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'expired-receipt-check', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-a', checkedOfferVersionRef: 'offer-a:v1', currentOfferVersionRef: 'offer-a:v1', verifiedOfferRef: expired.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } };
|
|
716
|
+
assert.throws(() => rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: after, receipt }), /receipt_verified_offer_expired/, 'expired capability is rejected at receipt write time');
|
|
717
|
+
ledger.close();
|
|
718
|
+
rmSync(root, { recursive: true, force: true });
|
|
719
|
+
}
|
|
720
|
+
{
|
|
721
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-expired-receipt-replay-'));
|
|
722
|
+
const ledger = ensureLedger(root);
|
|
723
|
+
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
724
|
+
const before = checkoutWorkspace(0, capability);
|
|
725
|
+
const after = checkoutWorkspace(1, capability);
|
|
726
|
+
const writer = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
727
|
+
const t = writer.startTask({ ...turn('task-expired-receipt-replay'), workspace: before });
|
|
728
|
+
writer.issueOperation(t.taskId, { ...action('historical-check'), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } });
|
|
729
|
+
const historicalReceipt = { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'historical-check', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-a', checkedOfferVersionRef: 'offer-a:v1', currentOfferVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } };
|
|
730
|
+
writer.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: after, receipt: historicalReceipt });
|
|
731
|
+
const reader = new BookingCopilotTaskRuntime(ensureLedger(root), { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T11:00:00.000Z' });
|
|
732
|
+
const replayed = reader.resumeTask(t.taskId);
|
|
733
|
+
assert.equal(replayed?.lastReceipt?.status, 'applied', 'historical accepted availability receipt remains replayable after capability expiry');
|
|
734
|
+
assert.equal(replayed?.lastReceipt?.observation.kind === 'offer.availability' ? replayed.lastReceipt.observation.verifiedOfferRef : undefined, capability.verifiedOfferRef);
|
|
735
|
+
reader['ledger'].close();
|
|
736
|
+
ledger.close();
|
|
737
|
+
rmSync(root, { recursive: true, force: true });
|
|
738
|
+
}
|
|
739
|
+
{
|
|
740
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-partial-verified-offer-'));
|
|
741
|
+
const ledger = ensureLedger(root);
|
|
742
|
+
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
743
|
+
const before = checkoutWorkspace(0, capability);
|
|
744
|
+
const after = checkoutWorkspace(1, capability);
|
|
745
|
+
const rt = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
746
|
+
const t = rt.startTask({ ...turn('task-partial-verified-offer'), workspace: before });
|
|
747
|
+
rt.issueOperation(t.taskId, { ...action('partial-verified-check'), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } });
|
|
748
|
+
const partialReceipt = { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'partial-verified-check', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-a', checkedOfferVersionRef: 'offer-a:v1', currentOfferVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: ['check_avail_unverified'] }, resultContract: { outcome: 'partial', hardCriteriaMet: false, factRefs: [], gapCodes: ['check_avail_unverified'], blockers: [], relaxationsApplied: [] } };
|
|
749
|
+
assert.throws(() => rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: after, receipt: partialReceipt }), /workspace_mismatch|receipt_verified|availability_receipt|invalid_receipt/, 'partial applied availability cannot publish a checkout-authorizing verified capability');
|
|
750
|
+
assert.throws(() => rt.issueOperation(t.taskId, { ...action('partial-verified-checkout'), kind: 'checkout.prepare', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1', verifiedOfferRef: capability.verifiedOfferRef } }), /receipt_required|verified_offer|offer_version/, 'partial availability cannot authorize a later checkout');
|
|
751
|
+
ledger.close();
|
|
752
|
+
rmSync(root, { recursive: true, force: true });
|
|
753
|
+
}
|
|
754
|
+
{
|
|
755
|
+
const baseline = {
|
|
756
|
+
...workspace(0),
|
|
757
|
+
searchDraft: { destination: { query: 'Dubai' } },
|
|
758
|
+
visibleHotels: [{ hotelRef: 'hotel-proof-1', name: 'Proof Hotel', factRefs: ['hotel:proof'] }],
|
|
759
|
+
loadedOffers: [loadedOffer('offer-proof-1', 'hotel-proof-1')],
|
|
760
|
+
focusedHotelRef: 'hotel-proof-1',
|
|
761
|
+
shortlistedOfferRefs: ['offer-proof-1'],
|
|
762
|
+
selectedOfferRef: 'offer-proof-1',
|
|
763
|
+
verifiedOffer: verifiedCapability('offer-proof-1', 'offer-proof-1:v1', '2026-09-01T10:30:00.000Z'),
|
|
764
|
+
};
|
|
765
|
+
const keepFields = ['focusedHotelRef', 'loadedOffers', 'shortlistedOfferRefs', 'selectedOfferRef', 'verifiedOffer'];
|
|
766
|
+
const cases = [];
|
|
767
|
+
for (const kind of ['search.run', 'search.patch']) {
|
|
768
|
+
cases.push({ name: `${kind} clear`, kind, mode: 'clear', expect: 'accept' });
|
|
769
|
+
for (const keep of keepFields)
|
|
770
|
+
cases.push({ name: `${kind} retain ${keep}`, kind, mode: 'keep', keepField: keep, expect: 'reject' });
|
|
771
|
+
cases.push({ name: `${kind} no-revision unchanged`, kind, mode: 'unchanged', expect: 'accept' });
|
|
772
|
+
cases.push({ name: `${kind} no-revision invalidation`, kind, mode: 'no-revision-mutation', expect: 'reject' });
|
|
773
|
+
}
|
|
774
|
+
cases.push({ name: 'non-search clear focus', kind: 'results.view.patch', mode: 'non-search-clear-focus', expect: 'reject' });
|
|
775
|
+
for (const [index, c] of cases.entries()) {
|
|
776
|
+
const caseRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-search-invalidation-case-'));
|
|
777
|
+
const caseLedger = ensureLedger(caseRoot);
|
|
778
|
+
try {
|
|
779
|
+
const rt = new BookingCopilotTaskRuntime(caseLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
780
|
+
const task = rt.startTask({ ...turn(`task-search-invalidation-${index}`), workspace: structuredClone(baseline) });
|
|
781
|
+
const actionId = `search-invalidation-${index}`;
|
|
782
|
+
let operation;
|
|
783
|
+
if (c.kind === 'search.patch')
|
|
784
|
+
operation = { ...action(actionId), kind: c.kind, input: { patch: { destination: { query: 'Dubai' } } } };
|
|
785
|
+
else if (c.kind === 'results.view.patch')
|
|
786
|
+
operation = { ...action(actionId), kind: c.kind, input: { patch: { sort: 'price_asc' } } };
|
|
787
|
+
else
|
|
788
|
+
operation = action(actionId);
|
|
789
|
+
rt.issueOperation(task.taskId, operation);
|
|
790
|
+
const afterRevision = c.mode === 'unchanged' || c.mode === 'no-revision-mutation' ? 0 : 1;
|
|
791
|
+
const after = structuredClone(baseline);
|
|
792
|
+
after.revision = afterRevision;
|
|
793
|
+
if (c.mode === 'non-search-clear-focus') {
|
|
794
|
+
delete after.focusedHotelRef;
|
|
795
|
+
after.results = { status: 'idle', sort: 'price_asc' };
|
|
796
|
+
}
|
|
797
|
+
else if (c.mode !== 'unchanged') {
|
|
798
|
+
delete after.focusedHotelRef;
|
|
799
|
+
delete after.selectedOfferRef;
|
|
800
|
+
delete after.verifiedOffer;
|
|
801
|
+
after.loadedOffers = [];
|
|
802
|
+
after.shortlistedOfferRefs = [];
|
|
803
|
+
after.results = { status: 'loading' };
|
|
804
|
+
if (c.keepField === 'focusedHotelRef')
|
|
805
|
+
after.focusedHotelRef = baseline.focusedHotelRef;
|
|
806
|
+
else if (c.keepField === 'loadedOffers')
|
|
807
|
+
after.loadedOffers = structuredClone(baseline.loadedOffers);
|
|
808
|
+
else if (c.keepField === 'shortlistedOfferRefs') {
|
|
809
|
+
// Keep the shortlist semantically valid so this case reaches the
|
|
810
|
+
// search invalidation invariant rather than the canonical workspace
|
|
811
|
+
// membership guard.
|
|
812
|
+
after.loadedOffers = structuredClone(baseline.loadedOffers);
|
|
813
|
+
after.shortlistedOfferRefs = [...baseline.shortlistedOfferRefs];
|
|
814
|
+
}
|
|
815
|
+
else if (c.keepField === 'selectedOfferRef') {
|
|
816
|
+
after.loadedOffers = structuredClone(baseline.loadedOffers);
|
|
817
|
+
after.selectedOfferRef = baseline.selectedOfferRef;
|
|
818
|
+
}
|
|
819
|
+
else if (c.keepField === 'verifiedOffer')
|
|
820
|
+
after.verifiedOffer = structuredClone(baseline.verifiedOffer);
|
|
821
|
+
}
|
|
822
|
+
const observation = c.kind === 'results.view.patch'
|
|
823
|
+
? { kind: 'results.state', matchedHotelRefs: ['hotel-proof-1'], visibleCount: 1 }
|
|
824
|
+
: { kind: 'search.state' };
|
|
825
|
+
const receipt = {
|
|
826
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId, contextRef: 'ctx-v2',
|
|
827
|
+
status: 'applied', revision: afterRevision, observation,
|
|
828
|
+
resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
|
|
829
|
+
};
|
|
830
|
+
let error;
|
|
831
|
+
try {
|
|
832
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: task.taskId, workspace: after, receipt });
|
|
833
|
+
}
|
|
834
|
+
catch (e) {
|
|
835
|
+
error = e;
|
|
836
|
+
}
|
|
837
|
+
if (c.expect === 'accept') {
|
|
838
|
+
assert.equal(error, undefined, `${c.name}: search invalidation accepts the post-action workspace`);
|
|
839
|
+
const resumed = rt.resumeTask(task.taskId);
|
|
840
|
+
assert.ok(resumed, `${c.name}: resumeTask returns a real task state`);
|
|
841
|
+
assert.equal(resumed.pendingAction, undefined, `${c.name}: accepted continuation clears the pending action`);
|
|
842
|
+
assert.equal(resumed.revision, afterRevision, `${c.name}: accepted continuation records the post-action revision`);
|
|
843
|
+
assert.ok(resumed.workspaceSnapshot, `${c.name}: accepted continuation retains the post-action workspace`);
|
|
844
|
+
assert.deepEqual(resumed.workspaceSnapshot, after, `${c.name}: accepted continuation's workspace snapshot matches`);
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
assert.ok(error instanceof Error, `${c.name}: invalidation must throw`);
|
|
848
|
+
const message = error.message;
|
|
849
|
+
if (c.keepField === 'verifiedOffer' && c.mode === 'keep')
|
|
850
|
+
assert.equal(message, 'invalid_receipt_continuation:$.workspace.verifiedOffer: loaded offer version required', `${c.name}: verifiedOffer residue is rejected by the schema loaded-version binding`);
|
|
851
|
+
else
|
|
852
|
+
assert.equal(message, 'workspace_mismatch', `${c.name}: invalidation rejects with workspace_mismatch`);
|
|
853
|
+
const resumed = rt.resumeTask(task.taskId);
|
|
854
|
+
assert.ok(resumed, `${c.name}: resumeTask returns a real task state even after a rejected receipt`);
|
|
855
|
+
assert.ok(resumed.pendingAction, `${c.name}: rejected continuation leaves the pending action intact`);
|
|
856
|
+
assert.equal(resumed.pendingAction.actionId, actionId, `${c.name}: rejected continuation preserves the original pending action id`);
|
|
857
|
+
assert.equal(resumed.pendingAction.expectedRevision, 0, `${c.name}: rejected continuation preserves the original expected revision`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
finally {
|
|
861
|
+
caseLedger.close();
|
|
862
|
+
rmSync(caseRoot, { recursive: true, force: true });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const operation = runtime.issueOperation(task.taskId, action('action-v2'));
|
|
867
|
+
assert.equal(operation.action.actionId, 'action-v2');
|
|
868
|
+
assert.equal(operation.action.kind, 'search.run');
|
|
869
|
+
assert.equal(runtime.resumeTask(task.taskId)?.pendingAction?.actionId, 'action-v2');
|
|
870
|
+
assert.throws(() => runtime.issueOperation(task.taskId, action('wrong-context', 0, { contextRef: 'ctx-other' })), /receipt_required/);
|
|
871
|
+
assert.throws(() => runtime.continueWithReceipt({
|
|
872
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: task.taskId,
|
|
873
|
+
workspace: workspace(1), receipt: {
|
|
874
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'wrong', contextRef: 'ctx-v2',
|
|
875
|
+
status: 'applied', revision: 1, observation: { kind: 'search.state', resultCount: 1 },
|
|
876
|
+
resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
|
|
877
|
+
},
|
|
878
|
+
}), /receipt_action_mismatch/);
|
|
879
|
+
const receipt = {
|
|
880
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'action-v2', contextRef: 'ctx-v2',
|
|
881
|
+
status: 'needs_input', revision: 1, observation: { kind: 'gap', code: 'criterion_must_not_met', factRefs: ['fact-v2'] },
|
|
882
|
+
resultContract: {
|
|
883
|
+
outcome: 'partial', hardCriteriaMet: false, factRefs: ['fact-v2'], gapCodes: ['criterion_must_not_met'],
|
|
884
|
+
blockers: [{ blockerId: 'blocker-v2', sourceActionId: 'action-v2', sourceReceiptDigest: '', scope: 'search',
|
|
885
|
+
code: 'criterion_must_not_met', criterionPath: 'searchDraft.destination', strength: 'must',
|
|
886
|
+
valueDigest: 'b'.repeat(64), valueLabel: 'Dubai', evidence: { factRefs: ['fact-v2'], gapCodes: ['criterion_must_not_met'] } }],
|
|
887
|
+
relaxationsApplied: [],
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
// The runtime binds sourceReceiptDigest to the durable receipt digest before approval is accepted.
|
|
891
|
+
const receiptWithDigest = runtime.withReceiptDigest(receipt);
|
|
892
|
+
const afterReceipt = runtime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: task.taskId, workspace: workspace(1), receipt: receiptWithDigest });
|
|
893
|
+
assert.equal(afterReceipt.awaitingApproval?.blocker.blockerId, 'blocker-v2');
|
|
894
|
+
assert.equal(afterReceipt.lastCompletedAction?.actionId, 'action-v2', 'accepted receipt retains its exact completed action checkpoint');
|
|
895
|
+
assert.equal(afterReceipt.lastCompletedAction?.kind, 'search.run', 'completed action kind stays ledger-derived instead of being inferred from observation text');
|
|
896
|
+
const offerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-target-'));
|
|
897
|
+
const offerRuntime = new BookingCopilotTaskRuntime(ensureLedger(offerRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
898
|
+
const offerTurn = turn('task-offer-target');
|
|
899
|
+
const offerTask = offerRuntime.startTask({ ...offerTurn, workspace: { ...offerTurn.workspace, visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }], loadedOffers: [loadedOffer('offer-a', 'hotel-a')] } });
|
|
900
|
+
offerRuntime.issueOperation(offerTask.taskId, { ...action('offer-action'), kind: 'offer.check', input: { offerRef: 'offer-a', offerVersionRef: 'offer-a:v1' } });
|
|
901
|
+
const offerReceipt = offerRuntime.withReceiptDigest({ ...receipt, actionId: 'offer-action', observation: availabilityObservation('offer-b', 'offer-b:v1', true), resultContract: { ...receipt.resultContract, blockers: [], gapCodes: [] } });
|
|
902
|
+
const offerWorkspace1 = { ...offerTurn.workspace, revision: 1, visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: [] }], loadedOffers: [loadedOffer('offer-a', 'hotel-a')] };
|
|
903
|
+
assert.throws(() => offerRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: offerTask.taskId, workspace: offerWorkspace1, receipt: offerReceipt }), /receipt_target_mismatch/);
|
|
904
|
+
// An offers.view.patch receipt is a claim about the post-action HotelRates
|
|
905
|
+
// registry, not an authority of its own. A forged ref or count must be
|
|
906
|
+
// rejected before it can be handed to the planner as an offers.refined
|
|
907
|
+
// terminal observation.
|
|
908
|
+
const viewBindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-view-binding-'));
|
|
909
|
+
const viewBindingRuntime = new BookingCopilotTaskRuntime(ensureLedger(viewBindingRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
910
|
+
const viewBindingWorkspace0 = {
|
|
911
|
+
...workspace(0),
|
|
912
|
+
visibleHotels: [{ hotelRef: 'hotel-view-binding', name: 'View Binding Hotel', factRefs: [] }],
|
|
913
|
+
loadedOffers: [loadedOffer('offer-view-binding-a', 'hotel-view-binding')],
|
|
914
|
+
};
|
|
915
|
+
const viewBindingWorkspace1 = {
|
|
916
|
+
...viewBindingWorkspace0,
|
|
917
|
+
revision: 1,
|
|
918
|
+
loadedOffers: [
|
|
919
|
+
loadedOffer('offer-view-binding-a', 'hotel-view-binding'),
|
|
920
|
+
loadedOffer('offer-view-binding-b', 'hotel-view-binding'),
|
|
921
|
+
],
|
|
922
|
+
};
|
|
923
|
+
const viewBindingTask = viewBindingRuntime.startTask({ ...turn('task-offer-view-binding'), workspace: viewBindingWorkspace0 });
|
|
924
|
+
const viewBindingAction = {
|
|
925
|
+
...action('offer-view-binding-action'),
|
|
926
|
+
kind: 'offers.view.patch',
|
|
927
|
+
input: { hotelRef: 'hotel-view-binding', criteria: {} },
|
|
928
|
+
};
|
|
929
|
+
viewBindingRuntime.issueOperation(viewBindingTask.taskId, viewBindingAction);
|
|
930
|
+
const viewBindingReceipt = (offerRefs, actualCount = offerRefs.length) => ({
|
|
931
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'offer-view-binding-action', contextRef: 'ctx-v2',
|
|
932
|
+
status: 'applied', revision: 1,
|
|
933
|
+
observation: { kind: 'offers.state', hotelRefs: ['hotel-view-binding'], offerRefs, loadedHotelCount: 1 },
|
|
934
|
+
resultContract: { outcome: 'complete', requestedCount: 2, actualCount, hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
|
|
935
|
+
});
|
|
936
|
+
viewBindingRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: viewBindingTask.taskId, workspace: viewBindingWorkspace1, receipt: viewBindingReceipt(['offer-view-binding-a', 'offer-view-binding-b']) });
|
|
937
|
+
assert.equal(viewBindingRuntime.resumeTask(viewBindingTask.taskId)?.lastReceipt?.observation.kind, 'offers.state', 'offers.view.patch stores the receipt only after matching the post-action loaded offers');
|
|
938
|
+
const forgedRefRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-view-forged-ref-'));
|
|
939
|
+
const forgedRefRuntime = new BookingCopilotTaskRuntime(ensureLedger(forgedRefRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
940
|
+
const forgedRefTask = forgedRefRuntime.startTask({ ...turn('task-offer-view-forged-ref'), workspace: viewBindingWorkspace0 });
|
|
941
|
+
forgedRefRuntime.issueOperation(forgedRefTask.taskId, { ...viewBindingAction, actionId: 'offer-view-forged-ref-action' });
|
|
942
|
+
const forgedRefReceipt = { ...viewBindingReceipt(['offer-view-binding-a', 'offer-forged']), actionId: 'offer-view-forged-ref-action' };
|
|
943
|
+
assert.throws(() => forgedRefRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: forgedRefTask.taskId, workspace: viewBindingWorkspace1, receipt: forgedRefReceipt }), /availability_offers_unreported/, 'a completed view receipt cannot be replayed with a forged offer ref');
|
|
944
|
+
assert.equal(forgedRefRuntime.resumeTask(forgedRefTask.taskId)?.lastReceipt, undefined, 'a forged view receipt never reaches the durable ledger');
|
|
945
|
+
// Use a fresh task for the count case because the valid receipt above has
|
|
946
|
+
// already advanced the durable task to its terminal target.
|
|
947
|
+
const forgedCountRuntime = new BookingCopilotTaskRuntime(ensureLedger(mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-view-count-'))), { contextRefFactory: () => 'ctx-v2' });
|
|
948
|
+
const forgedCountTask = forgedCountRuntime.startTask({ ...turn('task-offer-view-count'), workspace: viewBindingWorkspace0 });
|
|
949
|
+
forgedCountRuntime.issueOperation(forgedCountTask.taskId, { ...viewBindingAction, actionId: 'offer-view-count-action' });
|
|
950
|
+
assert.throws(() => forgedCountRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: forgedCountTask.taskId, workspace: viewBindingWorkspace1, receipt: { ...viewBindingReceipt(['offer-view-binding-a', 'offer-view-binding-b'], 1), actionId: 'offer-view-count-action' } }), /availability_offers_count_mismatch/, 'a view receipt cannot report an actualCount different from its authoritative offer refs');
|
|
951
|
+
viewBindingRuntime['ledger'].close();
|
|
952
|
+
forgedRefRuntime['ledger'].close();
|
|
953
|
+
forgedCountRuntime['ledger'].close();
|
|
954
|
+
rmSync(viewBindingRoot, { recursive: true, force: true });
|
|
955
|
+
rmSync(forgedRefRoot, { recursive: true, force: true });
|
|
956
|
+
const offerCheckWorkspace = (revision, selectedOfferRef, shortlistedOfferRefs = ['offer-cleanup', 'offer-peer'], verifiedOffer) => ({ ...workspace(revision), visibleHotels: [{ hotelRef: 'hotel-cleanup', name: 'Hotel Cleanup', factRefs: [] }], loadedOffers: [loadedOffer('offer-cleanup', 'hotel-cleanup'), loadedOffer('offer-peer', 'hotel-cleanup')], selectedOfferRef, shortlistedOfferRefs, ...(verifiedOffer ? { verifiedOffer } : {}) });
|
|
957
|
+
const checkReceipt = (actionId, status, available = status === 'changed') => ({ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId, contextRef: 'ctx-v2', status, revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-cleanup', checkedOfferVersionRef: 'offer-cleanup:v1', currentOfferVersionRef: available ? (status === 'changed' ? 'offer-cleanup:v2' : 'offer-cleanup:v1') : undefined, available, verifiedOfferRef: available && status !== 'changed' ? 'verified-offer-cleanup' : undefined, changedFactRefs: status === 'changed' ? ['price'] : [], gapCodes: [] }, resultContract: { outcome: status === 'changed' ? 'partial' : available ? 'complete' : status === 'unavailable' || status === 'no_match' ? 'empty' : 'partial', hardCriteriaMet: available && status !== 'changed', factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } });
|
|
958
|
+
const assertOfferCheckContinuation = (name, before, after, receipt, pattern) => {
|
|
959
|
+
const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${name}-`));
|
|
960
|
+
const rt = new BookingCopilotTaskRuntime(ensureLedger(root), { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
961
|
+
const t = rt.startTask({ ...turn(`task-${name}`), workspace: before });
|
|
962
|
+
rt.issueOperation(t.taskId, { ...action(`${name}-action`), kind: 'offer.check', input: { offerRef: 'offer-cleanup', offerVersionRef: 'offer-cleanup:v1' } });
|
|
963
|
+
if (pattern)
|
|
964
|
+
assert.throws(() => rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: after, receipt: { ...receipt, actionId: `${name}-action` } }), pattern);
|
|
965
|
+
else
|
|
966
|
+
rt.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: t.taskId, workspace: after, receipt: { ...receipt, actionId: `${name}-action` } });
|
|
967
|
+
rt['ledger'].close();
|
|
968
|
+
rmSync(root, { recursive: true, force: true });
|
|
969
|
+
};
|
|
970
|
+
assertOfferCheckContinuation('offer-success-first-check', offerCheckWorkspace(0, undefined), offerCheckWorkspace(1, 'offer-cleanup', ['offer-cleanup', 'offer-peer'], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-first-check-action', 'applied', true));
|
|
971
|
+
assertOfferCheckContinuation('offer-success-peer-to-checked', offerCheckWorkspace(0, 'offer-peer'), offerCheckWorkspace(1, 'offer-cleanup', ['offer-cleanup', 'offer-peer'], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-peer-to-checked-action', 'applied', true));
|
|
972
|
+
assertOfferCheckContinuation('offer-success-cleared-forbidden', offerCheckWorkspace(0, 'offer-peer'), offerCheckWorkspace(1, undefined, ['offer-peer'], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-cleared-forbidden-action', 'applied', true), /workspace_mismatch/);
|
|
973
|
+
const cleanupRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-'));
|
|
974
|
+
const cleanupRuntime = new BookingCopilotTaskRuntime(ensureLedger(cleanupRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
975
|
+
const cleanupWorkspace0 = { ...workspace(0), visibleHotels: [{ hotelRef: 'hotel-cleanup', name: 'Hotel Cleanup', factRefs: [] }], loadedOffers: [loadedOffer('offer-cleanup', 'hotel-cleanup'), loadedOffer('offer-peer', 'hotel-cleanup')], selectedOfferRef: 'offer-cleanup', shortlistedOfferRefs: ['offer-cleanup', 'offer-peer'], verifiedOffer: verifiedCapability('offer-cleanup', 'offer-cleanup:v1', '2026-09-01T10:30:00.000Z') };
|
|
976
|
+
const cleanupTask = cleanupRuntime.startTask({ ...turn('task-offer-cleanup'), workspace: cleanupWorkspace0 });
|
|
977
|
+
const cleanupAction = { ...action('offer-cleanup-action'), kind: 'offer.check', input: { offerRef: 'offer-cleanup', offerVersionRef: 'offer-cleanup:v1' } };
|
|
978
|
+
cleanupRuntime.issueOperation(cleanupTask.taskId, cleanupAction);
|
|
979
|
+
const cleanupReceipt = { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'offer-cleanup-action', contextRef: 'ctx-v2', status: 'unavailable', revision: 1, observation: { kind: 'offer.availability', offerRef: 'offer-cleanup', checkedOfferVersionRef: 'offer-cleanup:v1', available: false, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'empty', hardCriteriaMet: false, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } };
|
|
980
|
+
const cleanupWorkspace1 = { ...cleanupWorkspace0, revision: 1, selectedOfferRef: undefined, shortlistedOfferRefs: ['offer-peer'], verifiedOffer: undefined };
|
|
981
|
+
cleanupRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: cleanupTask.taskId, workspace: cleanupWorkspace1, receipt: cleanupReceipt });
|
|
982
|
+
assert.equal(cleanupRuntime.resumeTask(cleanupTask.taskId)?.lastReceipt?.status, 'unavailable', 'offer.check can clear only the checked selection/shortlist and stale verification while preserving loaded offer evidence');
|
|
983
|
+
const unavailableRows = cleanupRuntime['ledger'].countEvents();
|
|
984
|
+
assert.throws(() => cleanupRuntime.issueOperation(cleanupTask.taskId, action('search-during-offer-refresh', 1)), /availability_operation_incompatible/, 'direct runtime cannot bypass the required offers.query recovery step');
|
|
985
|
+
assert.throws(() => cleanupRuntime.issueOperation(cleanupTask.taskId, { ...action('observe-during-offer-refresh', 1), kind: 'order.observe', input: { orderRef: 'order-not-yet-created' } }), /action_intent_mismatch/, 'the durable verification waypoint rejects an order-observe overshoot during unavailable recovery');
|
|
986
|
+
assert.equal(cleanupRuntime['ledger'].countEvents(), unavailableRows, 'rejected unavailable-recovery actions append no rows');
|
|
987
|
+
assertOfferCheckContinuation('offer-negative-kept-checked-forbidden', cleanupWorkspace0, { ...cleanupWorkspace1, selectedOfferRef: 'offer-cleanup', shortlistedOfferRefs: ['offer-cleanup', 'offer-peer'] }, cleanupReceipt, /workspace_mismatch/);
|
|
988
|
+
const cleanupChangedWorkspace1 = { ...cleanupWorkspace0, revision: 1, loadedOffers: [{ ...loadedOffer('offer-cleanup', 'hotel-cleanup', 'offer-cleanup:v2'), factRefs: ['price'] }, loadedOffer('offer-peer', 'hotel-cleanup')], verifiedOffer: undefined };
|
|
989
|
+
const changedRuntimeRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-changed-runtime-'));
|
|
990
|
+
const changedRuntime = new BookingCopilotTaskRuntime(ensureLedger(changedRuntimeRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
991
|
+
const changedTask = changedRuntime.startTask({ ...turn('task-offer-changed-runtime'), workspace: cleanupWorkspace0 });
|
|
992
|
+
const changedIntent = { schemaVersion: 'booking.intent.v1', target: 'offer.verified' };
|
|
993
|
+
changedRuntime.issueOperation(changedTask.taskId, { ...action('offer-changed-seed-query'), kind: 'offers.query', input: { hotelRefs: ['hotel-cleanup'], criteria: {} } }, changedIntent);
|
|
994
|
+
const changedSeedWorkspace = { ...cleanupWorkspace0, revision: 1 };
|
|
995
|
+
changedRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: changedTask.taskId, workspace: changedSeedWorkspace, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'offer-changed-seed-query', contextRef: 'ctx-v2', status: 'applied', revision: 1, observation: { kind: 'offers.state', hotelRefs: ['hotel-cleanup'], offerRefs: ['offer-cleanup', 'offer-peer'], loadedHotelCount: 1 }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } });
|
|
996
|
+
changedRuntime.issueOperation(changedTask.taskId, { ...action('offer-changed-runtime-check', 1), kind: 'offer.check', input: { offerRef: 'offer-cleanup', offerVersionRef: 'offer-cleanup:v1' } }, changedIntent);
|
|
997
|
+
changedRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: changedTask.taskId, workspace: { ...cleanupChangedWorkspace1, revision: 2 }, receipt: { ...checkReceipt('offer-changed-runtime-check', 'changed'), revision: 2 } });
|
|
998
|
+
assert.equal(changedRuntime.resumeTask(changedTask.taskId)?.lastReceipt?.status, 'changed', 'runtime persists changed availability against a replacement offer version');
|
|
999
|
+
changedRuntime['ledger'].close();
|
|
1000
|
+
rmSync(changedRuntimeRoot, { recursive: true, force: true });
|
|
1001
|
+
// Existing search results may be checked directly before GoTry has issued an
|
|
1002
|
+
// offers.query. This is the product-critical v1 -> v2 recovery path: the
|
|
1003
|
+
// changed receipt must survive restart, then the replacement version must be
|
|
1004
|
+
// the only admissible next CheckAvail candidate.
|
|
1005
|
+
const directChangedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-direct-offer-changed-'));
|
|
1006
|
+
const directChangedRuntime = new BookingCopilotTaskRuntime(ensureLedger(directChangedRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
1007
|
+
const directChangedTask = directChangedRuntime.startTask({ ...turn('task-direct-offer-changed'), workspace: cleanupWorkspace0 });
|
|
1008
|
+
directChangedRuntime.issueOperation(directChangedTask.taskId, { ...action('direct-offer-check-v1'), kind: 'offer.check', input: { offerRef: 'offer-cleanup', offerVersionRef: 'offer-cleanup:v1' } });
|
|
1009
|
+
const directChangedWorkspace = { ...cleanupChangedWorkspace1, revision: 1 };
|
|
1010
|
+
directChangedRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: directChangedTask.taskId, workspace: directChangedWorkspace, receipt: checkReceipt('direct-offer-check-v1', 'changed') });
|
|
1011
|
+
assert.equal(directChangedRuntime.resumeTask(directChangedTask.taskId)?.lastReceipt?.status, 'changed', 'direct first CheckAvail changed receipt is durable');
|
|
1012
|
+
const directChangedRestart = new BookingCopilotTaskRuntime(ensureLedger(directChangedRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
1013
|
+
const resumedDirect = directChangedRestart.resumeTask(directChangedTask.taskId);
|
|
1014
|
+
assert.equal(resumedDirect?.lastReceipt?.observation.kind, 'offer.availability');
|
|
1015
|
+
assert.equal(resumedDirect?.lastReceipt?.observation.kind === 'offer.availability' ? resumedDirect.lastReceipt.observation.currentOfferVersionRef : undefined, 'offer-cleanup:v2', 'restart retains replacement offer version');
|
|
1016
|
+
const changedRows = directChangedRestart['ledger'].countEvents();
|
|
1017
|
+
assert.throws(() => directChangedRestart.issueOperation(directChangedTask.taskId, action('search-during-recheck', 1)), /availability_operation_incompatible/, 'direct runtime cannot bypass the required replacement-version recheck');
|
|
1018
|
+
assert.equal(directChangedRestart['ledger'].countEvents(), changedRows, 'rejected changed-recovery action appends no rows');
|
|
1019
|
+
const recheckV2 = { ...action('direct-offer-check-v2', 1), kind: 'offer.check', input: { offerRef: 'offer-cleanup', offerVersionRef: 'offer-cleanup:v2' } };
|
|
1020
|
+
directChangedRestart.issueOperation(directChangedTask.taskId, recheckV2);
|
|
1021
|
+
directChangedRuntime['ledger'].close();
|
|
1022
|
+
directChangedRestart['ledger'].close();
|
|
1023
|
+
rmSync(directChangedRoot, { recursive: true, force: true });
|
|
1024
|
+
assertOfferCheckContinuation('offer-no-match-cleanup', cleanupWorkspace0, cleanupWorkspace1, { ...cleanupReceipt, status: 'no_match' });
|
|
1025
|
+
assertOfferCheckContinuation('offer-failed-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, { ...cleanupReceipt, status: 'failed', resultContract: { ...cleanupReceipt.resultContract, outcome: 'partial' } }, /workspace_mismatch/);
|
|
1026
|
+
assertOfferCheckContinuation('offer-stale-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, { ...cleanupReceipt, status: 'stale', resultContract: { ...cleanupReceipt.resultContract, outcome: 'partial' } }, /workspace_mismatch/);
|
|
1027
|
+
assertOfferCheckContinuation('offer-unsupported-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, { ...cleanupReceipt, status: 'unsupported', resultContract: { ...cleanupReceipt.resultContract, outcome: 'partial' } }, /workspace_mismatch/);
|
|
1028
|
+
const replacedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-replaced-'));
|
|
1029
|
+
const replacedRuntime = new BookingCopilotTaskRuntime(ensureLedger(replacedRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
1030
|
+
const replacedTask = replacedRuntime.startTask({ ...turn('task-offer-cleanup-replaced'), workspace: cleanupWorkspace0 });
|
|
1031
|
+
replacedRuntime.issueOperation(replacedTask.taskId, { ...cleanupAction, actionId: 'offer-cleanup-replaced' });
|
|
1032
|
+
assert.throws(() => replacedRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: replacedTask.taskId, workspace: { ...cleanupWorkspace1, selectedOfferRef: 'offer-peer' }, receipt: { ...cleanupReceipt, actionId: 'offer-cleanup-replaced' } }), /workspace_mismatch/, 'offer.check cannot replace selection with a different offer');
|
|
1033
|
+
const loadedDriftRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-loaded-drift-'));
|
|
1034
|
+
const loadedDriftRuntime = new BookingCopilotTaskRuntime(ensureLedger(loadedDriftRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
1035
|
+
const loadedDriftTask = loadedDriftRuntime.startTask({ ...turn('task-offer-cleanup-loaded-drift'), workspace: cleanupWorkspace0 });
|
|
1036
|
+
loadedDriftRuntime.issueOperation(loadedDriftTask.taskId, { ...cleanupAction, actionId: 'offer-cleanup-loaded-drift' });
|
|
1037
|
+
assert.throws(() => loadedDriftRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: loadedDriftTask.taskId, workspace: { ...cleanupWorkspace1, loadedOffers: [loadedOffer('offer-peer', 'hotel-cleanup')] }, receipt: { ...cleanupReceipt, actionId: 'offer-cleanup-loaded-drift' } }), /workspace_mismatch/, 'offer.check cannot mutate loadedOffers evidence');
|
|
1038
|
+
cleanupRuntime['ledger'].close();
|
|
1039
|
+
replacedRuntime['ledger'].close();
|
|
1040
|
+
loadedDriftRuntime['ledger'].close();
|
|
1041
|
+
rmSync(cleanupRoot, { recursive: true, force: true });
|
|
1042
|
+
rmSync(replacedRoot, { recursive: true, force: true });
|
|
1043
|
+
rmSync(loadedDriftRoot, { recursive: true, force: true });
|
|
1044
|
+
const approvalQuestion = runtime.approvalQuestion(task.taskId);
|
|
1045
|
+
assert.equal(approvalQuestion.kind, 'question');
|
|
1046
|
+
if (approvalQuestion.kind === 'question') {
|
|
1047
|
+
assert.equal(approvalQuestion.question.blocker.blockerId, 'blocker-v2');
|
|
1048
|
+
assert.equal(approvalQuestion.question.approvalOptions[0]?.approval.sourceActionId, 'action-v2');
|
|
1049
|
+
}
|
|
1050
|
+
const awaitingRestart = new BookingCopilotTaskRuntime(ensureLedger(stateRoot), { now: () => '2026-09-01T10:00:00.000Z' });
|
|
1051
|
+
assert.equal(awaitingRestart.resumeTask(task.taskId)?.awaitingApproval?.blocker.blockerId, 'blocker-v2');
|
|
1052
|
+
const approval = approvalQuestion.kind === 'question' ? approvalQuestion.question.approvalOptions[0].approval : (() => { throw new Error('missing approval option'); })();
|
|
1053
|
+
assert.throws(() => runtime.startTask({ ...turn(task.taskId, 1), request: { text: 'relax destination', approval } }), /approval_not_presented/);
|
|
1054
|
+
// A persisted outbox intent alone is not a presentation. If the process
|
|
1055
|
+
// dies before the durable question batch, even a recovered option is refused.
|
|
1056
|
+
const awaitingBeforeBatch = runtime.resumeTask(task.taskId)?.awaitingApproval;
|
|
1057
|
+
assert.ok(awaitingBeforeBatch);
|
|
1058
|
+
const approvalPresentationKey = runtime.approvalPresentationRequestKey(task.taskId);
|
|
1059
|
+
runtime.ensureApprovalOffered(task.taskId, task.contextRef, awaitingBeforeBatch, approvalPresentationKey);
|
|
1060
|
+
assert.equal(runtime.resumeTask(task.taskId)?.awaitingApproval?.optionsEmitted, false);
|
|
1061
|
+
assert.throws(() => runtime.startTask({ ...turn(task.taskId, 1), request: { text: 'relax destination', approval } }), /approval_not_presented/);
|
|
1062
|
+
assert.throws(() => runtime.commitDecisionBatch(task.taskId, 'turn:unrelated-turn', [approvalQuestion]), /approval_presentation_key_mismatch/);
|
|
1063
|
+
assert.throws(() => runtime.commitDecisionBatch(task.taskId, `approval:other-task:turn-1:action-v2:${'c'.repeat(64)}`, [approvalQuestion]), /approval_presentation_key_mismatch/);
|
|
1064
|
+
const alteredSourceTurnQuestion = approvalQuestion.kind === 'question' ? { ...approvalQuestion, question: { ...approvalQuestion.question, approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option }) => ({ approval: { ...option, sourceTurnId: 'turn-forged' } })) } } : approvalQuestion;
|
|
1065
|
+
assert.throws(() => runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [alteredSourceTurnQuestion]), /approval_presentation_key_mismatch/);
|
|
1066
|
+
const alteredReceiptQuestion = approvalQuestion.kind === 'question' ? { ...approvalQuestion, question: { ...approvalQuestion.question, approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option }) => ({ approval: { ...option, sourceReceiptDigest: 'd'.repeat(64) } })) } } : approvalQuestion;
|
|
1067
|
+
assert.throws(() => runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [alteredReceiptQuestion]), /approval_presentation_key_mismatch/);
|
|
1068
|
+
const alteredOptionQuestion = approvalQuestion.kind === 'question' ? { ...approvalQuestion, question: { ...approvalQuestion.question, approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option }) => ({ approval: { ...option, optionDigest: 'e'.repeat(64) } })) } } : approvalQuestion;
|
|
1069
|
+
assert.throws(() => runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [alteredOptionQuestion]), /approval_presentation_key_mismatch/);
|
|
1070
|
+
runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [approvalQuestion]);
|
|
1071
|
+
assert.equal(runtime.resumeTask(task.taskId)?.awaitingApproval?.optionsEmitted, true, 'question batch is the presentation authority');
|
|
1072
|
+
assert.throws(() => runtime.startTask({ ...turn(task.taskId, 1), request: { text: 'relax destination', approval: { ...approval, deliveryNonce: 'forged-delivery-nonce' } } }), /approval_mismatch/);
|
|
1073
|
+
runtime.startTask({ ...turn(task.taskId, 1), request: { text: 'relax destination', approval } });
|
|
1074
|
+
const approvalEventCount = runtime['ledger'].countEvents();
|
|
1075
|
+
runtime.startTask({ ...turn(task.taskId, 1), request: { text: 'relax destination', approval } });
|
|
1076
|
+
assert.equal(runtime['ledger'].countEvents(), approvalEventCount, 'identical durable approval retry is idempotent');
|
|
1077
|
+
assert.throws(() => runtime.startTask({ ...turn(task.taskId, 1), turnId: 'tampered-approval-turn', request: { text: 'relax destination', approval: { ...approval, to: 'drop' } } }), /approval_mismatch/);
|
|
1078
|
+
const expiredRuntime = new BookingCopilotTaskRuntime(ensureLedger(stateRoot), { now: () => '2026-09-01T10:03:00.000Z' });
|
|
1079
|
+
assert.throws(() => expiredRuntime.issueOperation(task.taskId, action('action-v2-expired', 1)), /approval_expired/);
|
|
1080
|
+
const approvedOperation = runtime.issueOperation(task.taskId, action('action-v2-relaxed', 1));
|
|
1081
|
+
assert.equal(approvedOperation.action.relaxationApprovalRef?.targetActionId, 'action-v2-relaxed');
|
|
1082
|
+
assert.equal(approvedOperation.action.relaxationApprovalRef?.targetActionKind, 'search.run');
|
|
1083
|
+
assert.throws(() => runtime.issueOperation(task.taskId, action('action-v2-relaxed-again', 1)), /receipt_required/);
|
|
1084
|
+
// A replay with a fresh request key/turn id must fail closed after the
|
|
1085
|
+
// runtime has consumed the canonical approval, before planner admission.
|
|
1086
|
+
assert.throws(() => runtime.assertApprovalNotConsumed(task.taskId, approval), /approval_replayed/);
|
|
1087
|
+
const reopened = new BookingCopilotTaskRuntime(ensureLedger(stateRoot), { now: () => '2026-09-01T10:00:00.000Z' });
|
|
1088
|
+
assert.equal(reopened.resumeTask(task.taskId)?.lastReceipt?.actionId, 'action-v2');
|
|
1089
|
+
assert.equal(reopened.resumeTask(task.taskId)?.pendingAction?.actionId, 'action-v2-relaxed');
|
|
1090
|
+
assert.throws(() => reopened.issueOperation(task.taskId, { ...action('forged-ref', 1), relaxationApprovalRef: { ...approvedOperation.action.relaxationApprovalRef, targetActionId: 'forged-ref' } }), /receipt_required/);
|
|
1091
|
+
const forgedTask = runtime.startTask(turn('task-forged', 1));
|
|
1092
|
+
assert.throws(() => runtime.issueOperation(forgedTask.taskId, action('forged-ref', 1, { relaxationApprovalRef: { ...approvedOperation.action.relaxationApprovalRef, targetActionId: 'forged-ref' } })), /approval_ref_planner_owned_forbidden/);
|
|
1093
|
+
const forgedSourceRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-forged-source-'));
|
|
1094
|
+
const forgedSourceRuntime = new BookingCopilotTaskRuntime(ensureLedger(forgedSourceRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
1095
|
+
const forgedSourceTask = forgedSourceRuntime.startTask(turn('task-forged-source'));
|
|
1096
|
+
forgedSourceRuntime.issueOperation(forgedSourceTask.taskId, action('action-real'));
|
|
1097
|
+
const forgedSourceReceipt = forgedSourceRuntime.withReceiptDigest({ ...receipt, actionId: 'action-real', resultContract: { ...receipt.resultContract, blockers: [{ ...receipt.resultContract.blockers[0], sourceActionId: 'action-forged' }] } });
|
|
1098
|
+
assert.throws(() => forgedSourceRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: forgedSourceTask.taskId, workspace: workspace(1), receipt: forgedSourceReceipt }), /receipt_source_action_mismatch/);
|
|
1099
|
+
assert.equal(forgedSourceRuntime.resumeTask(forgedSourceTask.taskId)?.pendingAction?.actionId, 'action-real', 'forged blocker is rejected before receipt append');
|
|
1100
|
+
const tamperRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-tamper-'));
|
|
1101
|
+
const tamperLedger = ensureLedger(tamperRoot);
|
|
1102
|
+
const tamperRuntime = new BookingCopilotTaskRuntime(tamperLedger);
|
|
1103
|
+
tamperRuntime.startTask(turn('task-tamper'));
|
|
1104
|
+
tamperLedger.insertEvent({ actor: 'tamper', kind: 'booking.copilot.user.turn.observed', subjectId: 'task-tamper', runId: 'task-tamper', idemKey: 'tamper-turn', payload: { schema: 'booking.copilot.ledger', taskId: 'task-tamper', contextRef: 'ctx-v2', requestDigest: 'tampered', approval: { forged: true } } });
|
|
1105
|
+
assert.throws(() => tamperRuntime.resumeTask('task-tamper'), /ledger_corrupt:task-tamper:turn_approval/);
|
|
1106
|
+
const serverRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-server-'));
|
|
1107
|
+
let serverId = 0;
|
|
1108
|
+
const serverRuntime = new BookingCopilotTaskRuntime(ensureLedger(serverRoot), { idFactory: (prefix) => `${prefix}-server-${++serverId}`, contextRefFactory: () => 'ctx-server' });
|
|
1109
|
+
let factoryCalls = 0;
|
|
1110
|
+
let plannerCalls = 0;
|
|
1111
|
+
let resolveApprovalPlannerEntered;
|
|
1112
|
+
const approvalPlannerEntered = new Promise((resolve) => { resolveApprovalPlannerEntered = resolve; });
|
|
1113
|
+
let releaseApprovalPlanner;
|
|
1114
|
+
const approvalPlannerRelease = new Promise((resolve) => { releaseApprovalPlanner = resolve; });
|
|
1115
|
+
let approvalPlannerDeferred = false;
|
|
1116
|
+
const server = await startBookingCopilotServer({
|
|
1117
|
+
apiKey: 'v2-server-key',
|
|
1118
|
+
runtime: serverRuntime,
|
|
1119
|
+
principal: { subject: 'bff-principal-a', scope: 'booking:read' },
|
|
1120
|
+
ingressBinding: { bind: (input) => ({ taskId: 'task-server', turnId: `turn-${input.requestKey}`, contextRef: 'ctx-server', surface: 'tenant', allowedActions: [...BOOKING_FULL_JOURNEY_ACTION_KINDS] }) },
|
|
1121
|
+
ingressMode: 'bff-ingress-binding',
|
|
1122
|
+
plannerFactory: (initial) => { factoryCalls++; return { next: async ({ turn, task: current }) => { plannerCalls++; if (turn.kind === 'user.turn' && turn.request.approval && !approvalPlannerDeferred) {
|
|
1123
|
+
approvalPlannerDeferred = true;
|
|
1124
|
+
resolveApprovalPlannerEntered();
|
|
1125
|
+
await approvalPlannerRelease;
|
|
1126
|
+
} const decision = { kind: 'operation', action: { ...action(`server-action-${plannerCalls}`, current.revision), contextRef: current.contextRef }, intent: searchResultsIntent }; return [decision]; } }; },
|
|
1127
|
+
});
|
|
1128
|
+
const endpoint = `http://127.0.0.1:${server.port}/a2a/booking-copilot/turn`;
|
|
1129
|
+
const headers = { authorization: 'Bearer v2-server-key', 'content-type': 'application/json', 'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION, 'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_SHA256 };
|
|
1130
|
+
const ingress = { schemaVersion: 'booking.surface', kind: 'user.turn.ingress', requestKey: 'ingress-request-1', taskHandle: 'opaque-task-handle-1', surfaceHint: 'tenant', workspace: { schemaVersion: 'booking.surface', revision: 0, locale: 'en-US', currency: 'AED', searchDraft: {}, results: { status: 'idle' }, visibleHotels: [], loadedOffers: [], shortlistedOfferRefs: [] }, request: { text: 'find hotels in Dubai' } };
|
|
1131
|
+
const missingTurnLedgerEvents = serverRuntime['ledger'].countEvents();
|
|
1132
|
+
const missingIngress = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...ingress, requestKey: undefined }) });
|
|
1133
|
+
assert.equal(missingIngress.status, 400, 'missing ingress requestKey is rejected at HTTP schema boundary');
|
|
1134
|
+
const missingUser = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...turn('missing-user'), turnId: undefined }) });
|
|
1135
|
+
assert.equal(missingUser.status, 400, 'missing user turnId is rejected at HTTP schema boundary');
|
|
1136
|
+
const injectedIngressIdentity = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...ingress, taskId: 'browser-task', turnId: 'browser-turn', contextRef: null }) });
|
|
1137
|
+
assert.equal(injectedIngressIdentity.status, 400, 'browser-supplied identity is rejected at HTTP schema boundary');
|
|
1138
|
+
const missingUserTask = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...turn('missing-user-task'), taskId: undefined }) });
|
|
1139
|
+
assert.equal(missingUserTask.status, 400, 'missing user taskId is rejected at HTTP schema boundary');
|
|
1140
|
+
assert.equal(serverRuntime['ledger'].countEvents(), missingTurnLedgerEvents, 'missing taskId/turnId requests have no ledger side effects');
|
|
1141
|
+
assert.equal(plannerCalls, 0, 'missing taskId/turnId requests do not call planner');
|
|
1142
|
+
const first = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(ingress) });
|
|
1143
|
+
assert.equal(first.status, 200);
|
|
1144
|
+
const firstBody = await first.text();
|
|
1145
|
+
assert.match(firstBody, /event: operation/);
|
|
1146
|
+
assert.equal(factoryCalls, 1);
|
|
1147
|
+
assert.equal(plannerCalls, 1);
|
|
1148
|
+
assert.match(firstBody, /ctx-server/);
|
|
1149
|
+
const ingressReplayWhileWaiting = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(ingress) });
|
|
1150
|
+
assert.equal(ingressReplayWhileWaiting.status, 200, 'stable ingress turn identity replays while waiting for receipt');
|
|
1151
|
+
assert.equal(await ingressReplayWhileWaiting.text(), firstBody);
|
|
1152
|
+
assert.equal(plannerCalls, 1);
|
|
1153
|
+
const pending = serverRuntime.resumeTask('task-server')?.pendingAction;
|
|
1154
|
+
assert.ok(pending);
|
|
1155
|
+
const continuationWorkspace = { ...workspace(1), contextRef: 'ctx-server' };
|
|
1156
|
+
const postActionWorkspace = { ...continuationWorkspace, visibleHotels: [{ hotelRef: 'hotel-a', name: 'Hotel A', factRefs: ['fact-hotel-a'] }] };
|
|
1157
|
+
const receiptTurn = {
|
|
1158
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: 'task-server', workspace: postActionWorkspace,
|
|
1159
|
+
receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: pending.actionId, contextRef: 'ctx-server', status: 'applied', revision: 1,
|
|
1160
|
+
observation: { kind: 'search.state', resultCount: 1 },
|
|
1161
|
+
resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } },
|
|
1162
|
+
};
|
|
1163
|
+
const [second, concurrentSecond] = await Promise.all([
|
|
1164
|
+
fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(receiptTurn) }),
|
|
1165
|
+
fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(receiptTurn) }),
|
|
1166
|
+
]);
|
|
1167
|
+
assert.equal(second.status, 200);
|
|
1168
|
+
assert.equal(concurrentSecond.status, 200);
|
|
1169
|
+
const secondBody = await second.text();
|
|
1170
|
+
const concurrentSecondBody = await concurrentSecond.text();
|
|
1171
|
+
assert.equal(concurrentSecondBody, secondBody, 'concurrent identical receipt requests receive the same durable typed batch');
|
|
1172
|
+
assert.equal(plannerCalls, 2);
|
|
1173
|
+
const replay = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(receiptTurn) });
|
|
1174
|
+
assert.equal(replay.status, 200);
|
|
1175
|
+
const replayBody = await replay.text();
|
|
1176
|
+
assert.equal(plannerCalls, 2, 'identical durable receipt replay does not call planner again');
|
|
1177
|
+
assert.equal(replayBody, secondBody, 'receipt replay emits the exact durable SSE batch');
|
|
1178
|
+
const alteredReceiptWorkspace = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...receiptTurn, workspace: { ...continuationWorkspace, currency: 'USD' } }) });
|
|
1179
|
+
assert.equal(alteredReceiptWorkspace.status, 409, 'receipt replay binds the full workspace semantics');
|
|
1180
|
+
const alteredReceiptDraft = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...receiptTurn, workspace: { ...postActionWorkspace, searchDraft: { destination: { query: 'Abu Dhabi' } } } }) });
|
|
1181
|
+
assert.equal(alteredReceiptDraft.status, 409, 'receipt rejects unauthorized non-action workspace mutation');
|
|
1182
|
+
const pendingWithBlocker = serverRuntime.resumeTask('task-server')?.pendingAction;
|
|
1183
|
+
assert.ok(pendingWithBlocker);
|
|
1184
|
+
const blockerReceipt = serverRuntime.withReceiptDigest({ ...receipt, actionId: pendingWithBlocker.actionId, contextRef: 'ctx-server', revision: 2, resultContract: { ...receipt.resultContract, blockers: [{ ...receipt.resultContract.blockers[0], sourceActionId: pendingWithBlocker.actionId }] } });
|
|
1185
|
+
const blockerTurn = { ...receiptTurn, workspace: { ...continuationWorkspace, revision: 2 }, receipt: blockerReceipt };
|
|
1186
|
+
const blockerResponse = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(blockerTurn) });
|
|
1187
|
+
assert.equal(blockerResponse.status, 200);
|
|
1188
|
+
const blockerBody = await blockerResponse.text();
|
|
1189
|
+
assert.match(blockerBody, /relaxation_approval_required/);
|
|
1190
|
+
assert.equal(plannerCalls, 2, 'runtime-owned approval question does not call DSH planner');
|
|
1191
|
+
const questionApproval = serverRuntime.approvalQuestion('task-server');
|
|
1192
|
+
assert.equal(questionApproval.kind, 'question');
|
|
1193
|
+
const deliveredQuestionMatch = /event: question\ndata: ([^\n]+)/.exec(blockerBody);
|
|
1194
|
+
assert.ok(deliveredQuestionMatch, 'question SSE must be delivered before approval');
|
|
1195
|
+
const deliveredQuestion = JSON.parse(deliveredQuestionMatch[1]);
|
|
1196
|
+
const approved = questionApproval.kind === 'question' ? questionApproval.question.approvalOptions[0].approval : undefined;
|
|
1197
|
+
assert.ok(approved);
|
|
1198
|
+
assert.equal(deliveredQuestion.question.approvalOptions[0].approval.deliveryNonce, approved.deliveryNonce, 'SSE carries the durable unpredictable presentation nonce');
|
|
1199
|
+
const serverEventCountBeforeApprovalIngress = serverRuntime['ledger'].countEvents();
|
|
1200
|
+
const forgedApprovalIngress = { ...ingress, requestKey: 'forged-approval-ingress-request-1', workspace: { ...ingress.workspace, revision: 2 }, request: { text: 'prefer a nearby match', approval: { ...approved, taskId: 'browser-forged-task', contextRef: 'browser-forged-context' } } };
|
|
1201
|
+
const forgedApprovalResponse = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(forgedApprovalIngress) });
|
|
1202
|
+
assert.equal(forgedApprovalResponse.status, 400, 'mismatched browser approval authority is rejected before planner/runtime side effects');
|
|
1203
|
+
assert.match(await forgedApprovalResponse.text(), /invalid_ingress_approval_authority/);
|
|
1204
|
+
assert.equal(plannerCalls, 2, 'forged approval ingress does not call the planner');
|
|
1205
|
+
assert.equal(serverRuntime['ledger'].countEvents(), serverEventCountBeforeApprovalIngress, 'forged approval ingress does not append ledger events');
|
|
1206
|
+
const approvedIngress = { ...ingress, requestKey: 'approval-ingress-request-1', workspace: { ...ingress.workspace, revision: 2 }, request: { text: 'prefer a nearby match', approval: { ...approved } } };
|
|
1207
|
+
const approvedResponsePromise = fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(approvedIngress) });
|
|
1208
|
+
await approvalPlannerEntered;
|
|
1209
|
+
const concurrentApprovalPromises = ['approval-concurrent-a', 'approval-concurrent-b'].map((requestKey) => fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...approvedIngress, requestKey }) }));
|
|
1210
|
+
let concurrentApprovalResponses;
|
|
1211
|
+
let concurrentApprovalFailure;
|
|
1212
|
+
let rejectConcurrentTimeout;
|
|
1213
|
+
const concurrentTimeout = setTimeout(() => rejectConcurrentTimeout(new Error('concurrent approval replay joined the in-flight planner')), 2_000);
|
|
1214
|
+
try {
|
|
1215
|
+
concurrentApprovalResponses = await Promise.race([
|
|
1216
|
+
Promise.all(concurrentApprovalPromises),
|
|
1217
|
+
new Promise((_resolve, reject) => { rejectConcurrentTimeout = reject; }),
|
|
1218
|
+
]);
|
|
1219
|
+
}
|
|
1220
|
+
catch (cause) {
|
|
1221
|
+
concurrentApprovalFailure = cause;
|
|
1222
|
+
}
|
|
1223
|
+
finally {
|
|
1224
|
+
clearTimeout(concurrentTimeout);
|
|
1225
|
+
releaseApprovalPlanner();
|
|
1226
|
+
}
|
|
1227
|
+
const approvedResponse = await approvedResponsePromise;
|
|
1228
|
+
if (concurrentApprovalFailure)
|
|
1229
|
+
throw concurrentApprovalFailure;
|
|
1230
|
+
assert.ok(concurrentApprovalResponses);
|
|
1231
|
+
assert.ok(concurrentApprovalResponses.every(({ status }) => status === 409), 'concurrent exact approvals with fresh keys fail closed before planner replay');
|
|
1232
|
+
for (const response of concurrentApprovalResponses)
|
|
1233
|
+
assert.match(await response.text(), /ACTION_CONFLICT/, 'approval replay is a typed conflict at the HTTP boundary');
|
|
1234
|
+
assert.equal(approvedResponse.status, 200);
|
|
1235
|
+
const approvedBody = await approvedResponse.text();
|
|
1236
|
+
assert.equal(plannerCalls, 3);
|
|
1237
|
+
assert.match(approvedBody, /event: operation/);
|
|
1238
|
+
assert.equal(plannerCalls, 3, 'concurrent approval retries never enter planner');
|
|
1239
|
+
assert.equal(serverRuntime['ledger'].db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.approval.consumed' AND run_id = ?").get('task-server').n, 1, 'approval is consumed exactly once');
|
|
1240
|
+
const approvedPayloadRows = serverRuntime['ledger'].db.prepare("SELECT payload FROM events WHERE kind = 'booking.copilot.user.turn.observed' AND run_id = ? ORDER BY seq DESC").all('task-server');
|
|
1241
|
+
const approvedPayload = JSON.parse(approvedPayloadRows[0].payload);
|
|
1242
|
+
assert.equal(approvedPayload.contextRef, 'ctx-server');
|
|
1243
|
+
const consumedApprovalRows = serverRuntime['ledger'].db.prepare("SELECT payload FROM events WHERE kind = 'booking.copilot.approval.granted' AND run_id = ? ORDER BY seq DESC").all('task-server');
|
|
1244
|
+
const consumedApprovalPayload = JSON.parse(consumedApprovalRows[0].payload);
|
|
1245
|
+
assert.deepEqual(consumedApprovalPayload.approval.approval, approved, 'exact ingress approval is forwarded unchanged into runtime approval consumption');
|
|
1246
|
+
const crashRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-crash-replay-'));
|
|
1247
|
+
const crashLedger = ensureLedger(crashRoot);
|
|
1248
|
+
const crashV2 = new BookingCopilotTaskRuntime(crashLedger);
|
|
1249
|
+
const crashTurn = { ...turn('task-crash'), turnId: 'stable-turn-1' };
|
|
1250
|
+
crashV2.startTask(crashTurn);
|
|
1251
|
+
const crashCount = crashLedger.countEvents();
|
|
1252
|
+
crashV2.startTask(crashTurn);
|
|
1253
|
+
assert.equal(crashLedger.countEvents(), crashCount, 'stable turn identity retries after startTask without ledger delta');
|
|
1254
|
+
const duplicateTextTurn = { ...turn('task-crash'), turnId: 'stable-turn-2' };
|
|
1255
|
+
crashV2.startTask(duplicateTextTurn);
|
|
1256
|
+
assert.equal(crashV2.resumeTask('task-crash')?.userTurnCount, 2, 'same text with a new turn identity is a new turn');
|
|
1257
|
+
// STARTED/TURN/REQUEST_BINDING are one immediate transaction. An injected
|
|
1258
|
+
// failure at the binding insert rolls back the reservation, so the retry has
|
|
1259
|
+
// exactly one task and one binding rather than a second planner turn.
|
|
1260
|
+
const atomicRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-binding-atomic-'));
|
|
1261
|
+
const atomicLedger = ensureLedger(atomicRoot);
|
|
1262
|
+
const atomicRuntime = new BookingCopilotTaskRuntime(atomicLedger);
|
|
1263
|
+
const atomicTurn = turn('task-atomic-binding');
|
|
1264
|
+
const atomicBinding = { requestKey: 'atomic-request-1', principal: { subject: 'bff-atomic', scope: 'booking:read' }, taskHandle: 'atomic-task-handle' };
|
|
1265
|
+
atomicLedger.db.exec("CREATE TRIGGER abort_booking_request_binding BEFORE INSERT ON events WHEN NEW.kind = 'booking.copilot.request.binding' BEGIN SELECT RAISE(ABORT, 'crash_after_start_before_binding'); END");
|
|
1266
|
+
assert.throws(() => atomicRuntime.startTask(atomicTurn, atomicBinding), /crash_after_start_before_binding|SQLITE_CONSTRAINT/);
|
|
1267
|
+
assert.equal(atomicLedger.countEvents(), 0, 'binding failure rolls back STARTED and TURN reservation');
|
|
1268
|
+
atomicLedger.db.exec('DROP TRIGGER abort_booking_request_binding');
|
|
1269
|
+
atomicRuntime.startTask(atomicTurn, atomicBinding);
|
|
1270
|
+
const atomicCount = atomicLedger.countEvents();
|
|
1271
|
+
atomicRuntime.startTask(atomicTurn, atomicBinding);
|
|
1272
|
+
assert.equal(atomicLedger.countEvents(), atomicCount, 'same request binding retry does not create a second task/planner turn');
|
|
1273
|
+
assert.equal(atomicLedger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.request.binding'").get().n, 1);
|
|
1274
|
+
assert.throws(() => atomicRuntime.startTask(atomicTurn, { ...atomicBinding, principal: { subject: 'other-principal', scope: 'booking:read' } }), /principal_conflict|request_conflict/, 'taskHandle cannot be rebound across principals');
|
|
1275
|
+
assert.throws(() => atomicRuntime.startTask(turn('task-atomic-binding-other'), { ...atomicBinding, requestKey: 'atomic-request-2' }), /task_handle_conflict/, 'taskHandle cannot bind a second task in the same principal scope');
|
|
1276
|
+
atomicLedger.close();
|
|
1277
|
+
rmSync(atomicRoot, { recursive: true, force: true });
|
|
1278
|
+
const crossRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-cross-task-context-'));
|
|
1279
|
+
const crossLedger = ensureLedger(crossRoot);
|
|
1280
|
+
const crossRuntime = new BookingCopilotTaskRuntime(crossLedger);
|
|
1281
|
+
crossRuntime.startTask({ ...turn('task-cross-context'), workspace: { ...workspace(), contextRef: 'ctx-cross' } });
|
|
1282
|
+
assert.throws(() => crossRuntime.startTask({ ...turn('task-cross-context'), workspace: { ...workspace(), contextRef: 'ctx-other' } }), /task_conflict:context_mismatch/, 'one task id cannot drift across contexts');
|
|
1283
|
+
const reverseRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-reverse-context-'));
|
|
1284
|
+
const reverseLedger = ensureLedger(reverseRoot);
|
|
1285
|
+
const reverseRuntime = new BookingCopilotTaskRuntime(reverseLedger);
|
|
1286
|
+
reverseRuntime.startTask({ ...turn('task-reverse-context'), workspace: { ...workspace(), contextRef: 'ctx-a' } });
|
|
1287
|
+
assert.throws(() => reverseRuntime.startTask({ ...turn('task-reverse-context'), workspace: { ...workspace(), contextRef: 'ctx-b' } }), /task_conflict:context_mismatch/);
|
|
1288
|
+
const terminalRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-terminal-replay-'));
|
|
1289
|
+
const terminalLedger = ensureLedger(terminalRoot);
|
|
1290
|
+
const terminalRuntime = new BookingCopilotTaskRuntime(terminalLedger);
|
|
1291
|
+
const terminalTurn = { ...turn('task-terminal'), turnId: 'terminal-turn-1' };
|
|
1292
|
+
terminalRuntime.startTask(terminalTurn);
|
|
1293
|
+
terminalRuntime.applyDecisionBatch('task-terminal', 'turn:task-terminal:terminal-turn-1', [{ kind: 'terminal', terminal: { status: 'completed', summary: 'read-only result', factRefs: [] } }], true);
|
|
1294
|
+
assert.equal(terminalRuntime.resumeTask('task-terminal')?.phase, 'terminal');
|
|
1295
|
+
const terminalCount = terminalLedger.countEvents();
|
|
1296
|
+
assert.throws(() => terminalRuntime.startTask(terminalTurn), /task_terminal/);
|
|
1297
|
+
assert.equal(terminalLedger.countEvents(), terminalCount, 'terminal exact replay does not append a turn');
|
|
1298
|
+
assert.throws(() => terminalRuntime.startTask({ ...terminalTurn, turnId: 'terminal-new-turn' }), /task_terminal/);
|
|
1299
|
+
// A planner batch has one final boundary at most, and the boundary is last.
|
|
1300
|
+
// Reject malformed batches before any EVENT row is emitted.
|
|
1301
|
+
const finalityRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-batch-finality-'));
|
|
1302
|
+
const finalityLedger = ensureLedger(finalityRoot);
|
|
1303
|
+
const finalityRuntime = new BookingCopilotTaskRuntime(finalityLedger);
|
|
1304
|
+
finalityRuntime.startTask(turn('task-batch-finality'));
|
|
1305
|
+
const finalityRows = finalityLedger.countEvents();
|
|
1306
|
+
const finalityBytes = finalityLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n;
|
|
1307
|
+
const finalDecision = { kind: 'terminal', terminal: { status: 'completed', summary: 'one final', factRefs: [] } };
|
|
1308
|
+
assert.throws(() => finalityRuntime.applyDecisionBatch('task-batch-finality', 'batch-two-finals', [finalDecision, finalDecision], true), /decision_batch_finality/);
|
|
1309
|
+
assert.throws(() => finalityRuntime.applyDecisionBatch('task-batch-finality', 'batch-after-final', [finalDecision, { kind: 'explanation', explanation: { text: 'must not follow final', factRefs: [] } }], true), /decision_batch_finality/);
|
|
1310
|
+
assert.equal(finalityLedger.countEvents(), finalityRows, 'malformed final batches emit no events');
|
|
1311
|
+
assert.equal(finalityLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n, finalityBytes, 'malformed final batches do not alter payload bytes');
|
|
1312
|
+
finalityLedger.close();
|
|
1313
|
+
rmSync(finalityRoot, { recursive: true, force: true });
|
|
1314
|
+
// Internal decision ids are runtime-owned and may exceed the browser key
|
|
1315
|
+
// envelope. Long legal task/turn/action refs must still be replayable.
|
|
1316
|
+
const longRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-internal-key-'));
|
|
1317
|
+
const longLedger = ensureLedger(longRoot);
|
|
1318
|
+
const longRuntime = new BookingCopilotTaskRuntime(longLedger);
|
|
1319
|
+
const longTaskId = `task-${'t'.repeat(180)}`;
|
|
1320
|
+
const longTurnId = `turn-${'u'.repeat(180)}`;
|
|
1321
|
+
const longActionId = `action-${'a'.repeat(180)}`;
|
|
1322
|
+
longRuntime.startTask({ ...turn(longTaskId), turnId: longTurnId });
|
|
1323
|
+
const longDecisionKey = `turn:${longTaskId}:${longTurnId}:${longActionId}`;
|
|
1324
|
+
const longDecisionEvents = longRuntime.applyDecisionBatch(longTaskId, longDecisionKey, [{ kind: 'operation', action: action(longActionId), intent: searchResultsIntent }]);
|
|
1325
|
+
assert.equal(longDecisionEvents.some((event) => event.kind === 'operation' && event.action.actionId === longActionId), true, 'long internal decision key is accepted');
|
|
1326
|
+
longLedger.close();
|
|
1327
|
+
rmSync(longRoot, { recursive: true, force: true });
|
|
1328
|
+
// The operation budget is task-scoped and folded from durable action/receipt
|
|
1329
|
+
// rows, so a fresh runtime instance must enforce the same absorbing terminal.
|
|
1330
|
+
const budgetRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-operation-budget-'));
|
|
1331
|
+
const budgetLedger = ensureLedger(budgetRoot);
|
|
1332
|
+
const budgetRuntime = new BookingCopilotTaskRuntime(budgetLedger);
|
|
1333
|
+
const budgetTaskId = 'task-operation-budget';
|
|
1334
|
+
budgetRuntime.startTask(turn(budgetTaskId));
|
|
1335
|
+
let twentiethContinuation;
|
|
1336
|
+
for (let ordinal = 1; ordinal <= 20; ordinal++) {
|
|
1337
|
+
const currentTurn = { ...turn(budgetTaskId), turnId: `budget-turn-${ordinal}` };
|
|
1338
|
+
if (ordinal > 1)
|
|
1339
|
+
budgetRuntime.startTask(currentTurn);
|
|
1340
|
+
const operation = budgetRuntime.issueOperation(budgetTaskId, action(`budget-action-${ordinal}`));
|
|
1341
|
+
const continuation = { schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: budgetTaskId, workspace: workspace(0), receipt: budgetRuntime.withReceiptDigest({
|
|
1342
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: operation.action.actionId, contextRef: 'ctx-v2', status: 'applied', revision: 0,
|
|
1343
|
+
observation: { kind: 'search.state', resultCount: 0 }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
|
|
1344
|
+
}) };
|
|
1345
|
+
const folded = budgetRuntime.continueWithReceipt(continuation);
|
|
1346
|
+
assert.equal(folded.operationCount, ordinal);
|
|
1347
|
+
if (ordinal === 19)
|
|
1348
|
+
assert.equal(folded.phase, 'planning', '19th receipt remains continuable');
|
|
1349
|
+
if (ordinal === 20) {
|
|
1350
|
+
assert.equal(folded.phase, 'terminal');
|
|
1351
|
+
twentiethContinuation = continuation;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
assert.equal(budgetRuntime.resumeTask(budgetTaskId)?.operationCount, 20);
|
|
1355
|
+
assert.equal(budgetLedger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.event.emitted' AND json_extract(payload, '$.eventKind') = 'terminal'").get().n, 1, '20th receipt emits one durable terminal event');
|
|
1356
|
+
const terminalBatchCount = budgetLedger.countEvents();
|
|
1357
|
+
const terminalBatchBytes = budgetLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n;
|
|
1358
|
+
const budgetRestart = new BookingCopilotTaskRuntime(ensureLedger(budgetRoot));
|
|
1359
|
+
assert.equal(budgetRestart.resumeTask(budgetTaskId)?.operationCount, 20, 'operation count survives restart');
|
|
1360
|
+
assert.throws(() => budgetRestart.startTask({ ...turn(budgetTaskId), turnId: 'budget-turn-21' }), /task_terminal/);
|
|
1361
|
+
assert.throws(() => budgetRestart.issueOperation(budgetTaskId, action('budget-action-21')), /task_terminal/);
|
|
1362
|
+
assert.equal(budgetRestart.continueWithReceipt(twentiethContinuation).phase, 'terminal', 'exact terminal receipt replay is read-only');
|
|
1363
|
+
assert.throws(() => budgetRestart.emitEvent(budgetTaskId, { kind: 'explanation', explanation: { text: 'must reject', factRefs: [] } }), /task_terminal/);
|
|
1364
|
+
assert.throws(() => budgetRestart.terminalDecisionBatch(budgetTaskId, 'arbitrary-terminal-key'), /task_terminal/);
|
|
1365
|
+
assert.throws(() => budgetRestart.applyDecisionBatch(budgetTaskId, 'arbitrary-terminal-key-2', [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'must reject', factRefs: [] } }]), /task_terminal/);
|
|
1366
|
+
assert.throws(() => budgetRestart.commitDecisionBatch(budgetTaskId, 'arbitrary-terminal-key-3', []), /task_terminal/);
|
|
1367
|
+
assert.equal(budgetLedger.countEvents(), terminalBatchCount, 'post-terminal attempts do not mutate ledger');
|
|
1368
|
+
assert.equal(budgetLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n, terminalBatchBytes, 'post-terminal attempts do not mutate ledger bytes');
|
|
1369
|
+
budgetRestart['ledger'].close();
|
|
1370
|
+
budgetLedger.close();
|
|
1371
|
+
rmSync(budgetRoot, { recursive: true, force: true });
|
|
1372
|
+
// Per-requestKey bindings survive later turns and bind the request digest and
|
|
1373
|
+
// all workspace capability boundaries; a changed body under the same key is
|
|
1374
|
+
// a conflict, while replay of the original key is byte-for-byte durable.
|
|
1375
|
+
const bindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-request-binding-'));
|
|
1376
|
+
const bindingRuntime = new BookingCopilotTaskRuntime(ensureLedger(bindingRoot));
|
|
1377
|
+
const bindingTurn1 = { ...turn('task-request-binding'), turnId: 'binding-turn-1' };
|
|
1378
|
+
bindingRuntime.startTask(bindingTurn1);
|
|
1379
|
+
bindingRuntime.persistRequestBinding('request-one', bindingTurn1, { requestKey: 'request-one', principal: { subject: 'binding-principal', scope: 'booking:read' } });
|
|
1380
|
+
const firstBatch = bindingRuntime.applyDecisionBatch('task-request-binding', 'request-one', [{ kind: 'explanation', explanation: { text: 'first', factRefs: [] } }], true);
|
|
1381
|
+
const afterFirstBatch = bindingRuntime['ledger'].countEvents();
|
|
1382
|
+
bindingRuntime.startTask({ ...bindingTurn1, turnId: 'binding-turn-2' });
|
|
1383
|
+
const bindingTurn2 = { ...bindingTurn1, turnId: 'binding-turn-2' };
|
|
1384
|
+
bindingRuntime.persistRequestBinding('request-two', bindingTurn2, { requestKey: 'request-two', principal: { subject: 'binding-principal', scope: 'booking:read' } });
|
|
1385
|
+
assert.deepEqual(bindingRuntime.applyDecisionBatch('task-request-binding', 'request-one', [{ kind: 'error', error: { code: 'forged', message: 'forged', retryable: false } }]), firstBatch, 'R1 replay returns original durable batch after R2');
|
|
1386
|
+
assert.equal(bindingRuntime['ledger'].countEvents(), afterFirstBatch + 2, 'R1 replay and R2 binding do not append a replacement batch');
|
|
1387
|
+
assert.throws(() => bindingRuntime.assertRequestBinding('request-one', { ...bindingTurn1, request: { text: 'changed body' } }, { requestKey: 'request-one', principal: { subject: 'binding-principal', scope: 'booking:read' } }), /request_conflict/);
|
|
1388
|
+
bindingRuntime['ledger'].close();
|
|
1389
|
+
rmSync(bindingRoot, { recursive: true, force: true });
|
|
1390
|
+
// Old action/receipt rows without the new ordinal field fold in ACTION order.
|
|
1391
|
+
const legacyRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-ordinal-'));
|
|
1392
|
+
const legacyLedger = ensureLedger(legacyRoot);
|
|
1393
|
+
const legacyRuntime = new BookingCopilotTaskRuntime(legacyLedger);
|
|
1394
|
+
legacyRuntime.startTask(turn('task-legacy-ordinal'));
|
|
1395
|
+
const legacyOperation = legacyRuntime.issueOperation('task-legacy-ordinal', action('legacy-action'));
|
|
1396
|
+
legacyLedger.db.prepare("UPDATE events SET payload = json_remove(payload, '$.operationCount') WHERE kind = 'booking.copilot.action.issued'").run();
|
|
1397
|
+
const legacyReceipt = legacyRuntime.withReceiptDigest({ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: legacyOperation.action.actionId, contextRef: 'ctx-v2', status: 'applied', revision: 0, observation: { kind: 'search.state', resultCount: 0 }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } });
|
|
1398
|
+
legacyRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: 'task-legacy-ordinal', workspace: workspace(0), receipt: legacyReceipt });
|
|
1399
|
+
legacyLedger.db.prepare("UPDATE events SET payload = json_remove(payload, '$.operationCount') WHERE kind = 'booking.copilot.receipt.observed'").run();
|
|
1400
|
+
const legacyRestart = new BookingCopilotTaskRuntime(ensureLedger(legacyRoot));
|
|
1401
|
+
assert.equal(legacyRestart.resumeTask('task-legacy-ordinal')?.operationCount, 1, 'legacy action and receipt rows recover operation ordinal');
|
|
1402
|
+
legacyRestart['ledger'].close();
|
|
1403
|
+
legacyLedger.close();
|
|
1404
|
+
rmSync(legacyRoot, { recursive: true, force: true });
|
|
1405
|
+
const ordinalTamperRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-ordinal-tamper-'));
|
|
1406
|
+
const ordinalTamperLedger = ensureLedger(ordinalTamperRoot);
|
|
1407
|
+
const ordinalTamperRuntime = new BookingCopilotTaskRuntime(ordinalTamperLedger);
|
|
1408
|
+
ordinalTamperRuntime.startTask(turn('task-ordinal-tamper'));
|
|
1409
|
+
ordinalTamperRuntime.issueOperation('task-ordinal-tamper', action('ordinal-action'));
|
|
1410
|
+
ordinalTamperLedger.db.prepare("UPDATE events SET payload = json_set(payload, '$.operationCount', 7) WHERE kind = 'booking.copilot.action.issued'").run();
|
|
1411
|
+
const ordinalRestart = new BookingCopilotTaskRuntime(ensureLedger(ordinalTamperRoot));
|
|
1412
|
+
assert.throws(() => ordinalRestart.resumeTask('task-ordinal-tamper'), /ledger_corrupt:task-ordinal-tamper:action/, 'inconsistent explicit operation ordinal is rejected');
|
|
1413
|
+
ordinalRestart['ledger'].close();
|
|
1414
|
+
ordinalTamperLedger.close();
|
|
1415
|
+
rmSync(ordinalTamperRoot, { recursive: true, force: true });
|
|
1416
|
+
const rewriteLegacyOfferRows = (ledger, taskId, text, scalarVerifiedOfferRef) => {
|
|
1417
|
+
const rows = ledger.db.prepare("SELECT seq, kind, payload FROM events WHERE run_id = ? ORDER BY seq").all(taskId);
|
|
1418
|
+
const actionKinds = new Map();
|
|
1419
|
+
const actionWorkspaceDigests = new Map();
|
|
1420
|
+
const workspaceDigestMigration = new Map();
|
|
1421
|
+
let currentWorkspace;
|
|
1422
|
+
for (const row of rows) {
|
|
1423
|
+
if (row.kind !== 'booking.copilot.action.issued')
|
|
1424
|
+
continue;
|
|
1425
|
+
const payload = JSON.parse(row.payload);
|
|
1426
|
+
const action = payload.action;
|
|
1427
|
+
if (action && typeof action.actionId === 'string' && typeof action.kind === 'string')
|
|
1428
|
+
actionKinds.set(action.actionId, action.kind);
|
|
1429
|
+
}
|
|
1430
|
+
for (const row of rows) {
|
|
1431
|
+
const payload = JSON.parse(row.payload);
|
|
1432
|
+
const receiptActionId = row.kind === 'booking.copilot.receipt.observed' && payload.receipt && typeof payload.receipt.actionId === 'string' ? payload.receipt.actionId : undefined;
|
|
1433
|
+
if (payload.workspace && typeof payload.workspace === 'object') {
|
|
1434
|
+
const versionedWorkspace = structuredClone(payload.workspace);
|
|
1435
|
+
const oldWorkspace = legacyWorkspace(payload.workspace, scalarVerifiedOfferRef);
|
|
1436
|
+
payload.workspace = oldWorkspace;
|
|
1437
|
+
const oldDigest = oldWorkspaceDigest(oldWorkspace);
|
|
1438
|
+
workspaceDigestMigration.set(oldWorkspaceDigest(versionedWorkspace), oldDigest);
|
|
1439
|
+
payload.workspaceDigest = oldDigest;
|
|
1440
|
+
payload.workspaceSemanticDigest = oldWorkspaceSemanticDigest(oldWorkspace);
|
|
1441
|
+
currentWorkspace = oldWorkspace;
|
|
1442
|
+
}
|
|
1443
|
+
if (payload.availability && typeof payload.availability === 'object') {
|
|
1444
|
+
const actionId = payload.action && typeof payload.action === 'object' && typeof payload.action.actionId === 'string' ? payload.action.actionId : undefined;
|
|
1445
|
+
const availabilityValue = structuredClone(payload.availability);
|
|
1446
|
+
const sourceWorkspaceDigest = (() => {
|
|
1447
|
+
const hotels = availabilityValue.hotels;
|
|
1448
|
+
if (!hotels || typeof hotels !== 'object' || Array.isArray(hotels))
|
|
1449
|
+
return undefined;
|
|
1450
|
+
for (const hotel of Object.values(hotels)) {
|
|
1451
|
+
if (!hotel || typeof hotel !== 'object' || Array.isArray(hotel))
|
|
1452
|
+
continue;
|
|
1453
|
+
const generation = hotel.currentGeneration;
|
|
1454
|
+
if (!generation || typeof generation !== 'object' || Array.isArray(generation))
|
|
1455
|
+
continue;
|
|
1456
|
+
const source = generation.source;
|
|
1457
|
+
if (source && typeof source === 'object' && !Array.isArray(source) && source.kind === 'workspace_snapshot' && typeof source.workspaceDigest === 'string')
|
|
1458
|
+
return source.workspaceDigest;
|
|
1459
|
+
}
|
|
1460
|
+
return undefined;
|
|
1461
|
+
})();
|
|
1462
|
+
const sourceDigest = workspaceDigestMigration.get(sourceWorkspaceDigest ?? '') ?? (receiptActionId ? actionWorkspaceDigests.get(receiptActionId) : currentWorkspace ? oldWorkspaceDigest(currentWorkspace) : undefined);
|
|
1463
|
+
const oldAvailability = legacyAvailability(payload.availability, sourceDigest);
|
|
1464
|
+
payload.availability = oldAvailability;
|
|
1465
|
+
payload.availabilityDigest = bookingDigest(oldAvailability);
|
|
1466
|
+
if (actionId)
|
|
1467
|
+
actionWorkspaceDigests.set(actionId, currentWorkspace ? oldWorkspaceDigest(currentWorkspace) : '');
|
|
1468
|
+
}
|
|
1469
|
+
if (row.kind === 'booking.copilot.user.turn.observed' && typeof payload.turnId === 'string' && payload.workspace && typeof payload.workspace === 'object') {
|
|
1470
|
+
payload.requestDigest = legacyTurnDigest(taskId, payload.turnId, payload.workspace, text);
|
|
1471
|
+
}
|
|
1472
|
+
if (row.kind === 'booking.copilot.action.issued' && payload.action && typeof payload.action === 'object') {
|
|
1473
|
+
// booking.intent.v1 did not exist in the pre-versioned ledger family
|
|
1474
|
+
// represented by this migration fixture. Keeping a modern checkpoint
|
|
1475
|
+
// while rewriting its source TURN digest would create an impossible
|
|
1476
|
+
// mixed-era row rather than exercising the legacy replay path.
|
|
1477
|
+
delete payload.intent;
|
|
1478
|
+
const oldAction = payload.action;
|
|
1479
|
+
const input = oldAction.input;
|
|
1480
|
+
if (input && typeof input === 'object' && !Array.isArray(input) && ['offer.select', 'offer.check', 'checkout.prepare'].includes(String(oldAction.kind))) {
|
|
1481
|
+
delete input.offerVersionRef;
|
|
1482
|
+
}
|
|
1483
|
+
if (input && typeof input === 'object' && !Array.isArray(input))
|
|
1484
|
+
oldAction.inputDigest = bookingDigest(input);
|
|
1485
|
+
const { actionDigest: _oldActionDigest, ...actionBase } = oldAction;
|
|
1486
|
+
oldAction.actionDigest = bookingDigest(actionBase);
|
|
1487
|
+
}
|
|
1488
|
+
if (row.kind === 'booking.copilot.receipt.observed' && payload.receipt && typeof payload.receipt === 'object') {
|
|
1489
|
+
const actionId = typeof payload.receipt.actionId === 'string' ? payload.receipt.actionId : '';
|
|
1490
|
+
const oldReceipt = stripLegacyReceipt(payload.receipt);
|
|
1491
|
+
if (actionKinds.get(actionId) === 'checkout.prepare' && oldReceipt.observation && typeof oldReceipt.observation === 'object') {
|
|
1492
|
+
// A pre-versioned checkout carried only the legacy scalar verification
|
|
1493
|
+
// ref. It must never be upgraded into a live version capability.
|
|
1494
|
+
;
|
|
1495
|
+
oldReceipt.observation.verifiedOfferRef = scalarVerifiedOfferRef;
|
|
1496
|
+
}
|
|
1497
|
+
payload.receipt = oldReceipt;
|
|
1498
|
+
payload.receiptDigest = bookingDigest(oldReceipt);
|
|
1499
|
+
}
|
|
1500
|
+
if (row.kind === 'booking.copilot.decision.batch' && Array.isArray(payload.events)) {
|
|
1501
|
+
payload.events = payload.events.map((event) => {
|
|
1502
|
+
if (!event || typeof event !== 'object' || Array.isArray(event))
|
|
1503
|
+
return event;
|
|
1504
|
+
const oldEvent = event;
|
|
1505
|
+
if (oldEvent.kind !== 'operation' || !oldEvent.action || typeof oldEvent.action !== 'object' || Array.isArray(oldEvent.action))
|
|
1506
|
+
return oldEvent;
|
|
1507
|
+
const oldAction = oldEvent.action;
|
|
1508
|
+
const input = oldAction.input;
|
|
1509
|
+
if (input && typeof input === 'object' && !Array.isArray(input) && ['offer.select', 'offer.check', 'checkout.prepare'].includes(String(oldAction.kind)))
|
|
1510
|
+
delete input.offerVersionRef;
|
|
1511
|
+
return oldEvent;
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
// F1 same-revision replay guard regression: a TURN row written by an old
|
|
1518
|
+
// public startTask (no live-time same-revision guard) must not silently
|
|
1519
|
+
// rewrite the durable workspace semantic digest at the same revision. Replay
|
|
1520
|
+
// must fail closed as a typed ledger_corrupt diagnostic instead of turning
|
|
1521
|
+
// the replayed workspace into checkout authority. The deliberate
|
|
1522
|
+
// replayUpgradeRequired reanchor and confirmed-state strong rejection stay
|
|
1523
|
+
// intact; an unchanged same-revision TURN pair still replays normally.
|
|
1524
|
+
//
|
|
1525
|
+
// Lifecycle: every root/ledger is registered BEFORE any assertion so the
|
|
1526
|
+
// finally block can close + remove it even if the assertion throws (a
|
|
1527
|
+
// throw is exactly what regression tests must handle). f1MakeRoot
|
|
1528
|
+
// registers the path immediately after mkdtemp; f1Open registers the
|
|
1529
|
+
// ledger immediately after ensureLedger; f1Close closes once and
|
|
1530
|
+
// unregisters.
|
|
1531
|
+
const f1Roots = new Set();
|
|
1532
|
+
const f1Ledgers = new Set();
|
|
1533
|
+
const f1MakeRoot = (prefix) => { const r = mkdtempSync(join(tmpdir(), prefix)); f1Roots.add(r); return r; };
|
|
1534
|
+
const f1Open = (root) => { const l = ensureLedger(root); f1Ledgers.add(l); return l; };
|
|
1535
|
+
const f1Close = (ledger) => { if (!f1Ledgers.has(ledger))
|
|
1536
|
+
return; ledger.close(); f1Ledgers.delete(ledger); };
|
|
1537
|
+
const f1DriftVariants = [
|
|
1538
|
+
{
|
|
1539
|
+
name: 'verifiedOffer-injected',
|
|
1540
|
+
drift: (baseline) => ({ ...baseline, verifiedOffer: verifiedCapability('offer-f1', 'offer-f1:v1', '2026-09-01T10:15:00.000Z') }),
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
name: 'loadedOffer-version-drift',
|
|
1544
|
+
drift: (baseline) => ({ ...baseline, loadedOffers: [loadedOffer('offer-f1', 'hotel-f1', 'offer-f1:v2')] }),
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
name: 'selectedOffer-cleared',
|
|
1548
|
+
drift: (baseline) => ({ ...baseline, selectedOfferRef: undefined, shortlistedOfferRefs: [] }),
|
|
1549
|
+
},
|
|
1550
|
+
{
|
|
1551
|
+
name: 'hotel-renamed',
|
|
1552
|
+
drift: (baseline) => ({ ...baseline, visibleHotels: [{ ...baseline.visibleHotels[0], name: 'F1 Hotel Renamed' }] }),
|
|
1553
|
+
},
|
|
1554
|
+
];
|
|
1555
|
+
try {
|
|
1556
|
+
for (const variant of f1DriftVariants) {
|
|
1557
|
+
const root = f1MakeRoot(`gotry-booking-v2-f1-replay-${variant.name}-`);
|
|
1558
|
+
const ledger = f1Open(root);
|
|
1559
|
+
const taskId = `task-f1-replay-${variant.name}`;
|
|
1560
|
+
const baseline = {
|
|
1561
|
+
...workspace(0),
|
|
1562
|
+
visibleHotels: [{ hotelRef: 'hotel-f1', name: 'F1 Hotel', factRefs: [] }],
|
|
1563
|
+
loadedOffers: [loadedOffer('offer-f1', 'hotel-f1')],
|
|
1564
|
+
shortlistedOfferRefs: ['offer-f1'], selectedOfferRef: 'offer-f1',
|
|
1565
|
+
};
|
|
1566
|
+
const writer = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1567
|
+
writer.startTask({ ...turn(taskId), workspace: baseline });
|
|
1568
|
+
// Simulate a TURN row persisted by an old public startTask that did not
|
|
1569
|
+
// enforce the same-revision semantic CAS. We bypass live guards and
|
|
1570
|
+
// append the drifted TURN row directly through the private appendTurn,
|
|
1571
|
+
// which writes the row with the drifted workspace and its own digests.
|
|
1572
|
+
const drifted = variant.drift(baseline);
|
|
1573
|
+
const driftedWorkspaceDigest = oldWorkspaceDigest(drifted);
|
|
1574
|
+
const driftedSemanticDigest = oldWorkspaceSemanticDigest(drifted);
|
|
1575
|
+
const driftedRequestDigest = bookingDigest({ schemaVersion: 'booking.surface', kind: 'user.turn', taskId, turnId: `${variant.name}-turn`, workspace: drifted, request: { text: 'continue with the injected variant' } });
|
|
1576
|
+
writer.appendTurn(taskId, 'ctx-v2', driftedRequestDigest, driftedWorkspaceDigest, driftedSemanticDigest, 2, `${variant.name}-turn`, drifted);
|
|
1577
|
+
// Close the writer before opening the restart reader so SQLite handles
|
|
1578
|
+
// never overlap; the restart is a fresh runtime against the same
|
|
1579
|
+
// durable root in the same process.
|
|
1580
|
+
f1Close(ledger);
|
|
1581
|
+
const restartedLedger = f1Open(root);
|
|
1582
|
+
const reader = new BookingCopilotTaskRuntime(restartedLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1583
|
+
const eventsBeforeResume = restartedLedger.countEvents();
|
|
1584
|
+
assert.throws(() => reader.resumeTask(taskId), new RegExp(`ledger_corrupt:${taskId}:same_revision_turn_workspace_drift`), `${variant.name} same-rev drift is fail-closed on replay`);
|
|
1585
|
+
// Business-authority refusal: the same drifted ledger must also refuse a
|
|
1586
|
+
// public checkout.prepare with the exact new drift diagnostic, and must
|
|
1587
|
+
// not advance durable event count (no checkout row written).
|
|
1588
|
+
const eventsAfterResume = restartedLedger.countEvents();
|
|
1589
|
+
assert.equal(eventsAfterResume, eventsBeforeResume, `${variant.name} fail-closed replay does not write additional events`);
|
|
1590
|
+
assert.throws(() => reader.issueOperation(taskId, { ...action(`f1-${variant.name}-checkout`), kind: 'checkout.prepare', input: { offerRef: 'offer-f1', offerVersionRef: 'offer-f1:v1', verifiedOfferRef: 'verified-offer-f1' } }), new RegExp(`ledger_corrupt:${taskId}:same_revision_turn_workspace_drift`), `${variant.name} public checkout.prepare is refused with same-rev drift diagnostic`);
|
|
1591
|
+
const eventsAfterCheckout = restartedLedger.countEvents();
|
|
1592
|
+
assert.equal(eventsAfterCheckout, eventsBeforeResume, `${variant.name} refused checkout.prepare writes no event`);
|
|
1593
|
+
f1Close(restartedLedger);
|
|
1594
|
+
}
|
|
1595
|
+
// Positive: an unchanged snapshot at the same revision (just a different
|
|
1596
|
+
// turn identity and request text) is still accepted on replay. This is the
|
|
1597
|
+
// normal turn-by-turn conversation pattern under the same workspace.
|
|
1598
|
+
{
|
|
1599
|
+
const root = f1MakeRoot('gotry-booking-v2-f1-replay-snapshot-stable-');
|
|
1600
|
+
const ledger = f1Open(root);
|
|
1601
|
+
const taskId = 'task-f1-replay-snapshot-stable';
|
|
1602
|
+
const baseline = {
|
|
1603
|
+
...workspace(0),
|
|
1604
|
+
visibleHotels: [{ hotelRef: 'hotel-f1', name: 'F1 Hotel', factRefs: [] }],
|
|
1605
|
+
loadedOffers: [loadedOffer('offer-f1', 'hotel-f1')],
|
|
1606
|
+
shortlistedOfferRefs: ['offer-f1'], selectedOfferRef: 'offer-f1',
|
|
1607
|
+
};
|
|
1608
|
+
const writer = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1609
|
+
writer.startTask({ ...turn(taskId), workspace: baseline });
|
|
1610
|
+
writer.startTask({ ...turn(taskId), turnId: 'snapshot-stable-second-turn', workspace: baseline, request: { text: 'find hotels in Dubai again' } });
|
|
1611
|
+
f1Close(ledger);
|
|
1612
|
+
const restartedLedger = f1Open(root);
|
|
1613
|
+
const reader = new BookingCopilotTaskRuntime(restartedLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
1614
|
+
const replayed = reader.resumeTask(taskId);
|
|
1615
|
+
assert.equal(replayed?.userTurnCount, 2, 'same-snapshot same-rev TURN pair survives replay');
|
|
1616
|
+
assert.equal(replayed?.lastTurnId, 'snapshot-stable-second-turn', 'last turn id reflects the snapshot-stable second turn');
|
|
1617
|
+
assert.equal(replayed?.workspaceSnapshot?.selectedOfferRef, 'offer-f1', 'unchanged snapshot retains its selected offer ref');
|
|
1618
|
+
f1Close(restartedLedger);
|
|
1619
|
+
}
|
|
1620
|
+
// Positive: the explicit replayUpgradeRequired reanchor keeps accepting a
|
|
1621
|
+
// same-rev drifted TURN row. The legacy-tail replan path is set by an
|
|
1622
|
+
// ACTION/RECEIPT gap; once replayUpgradeRequired is true, the next public
|
|
1623
|
+
// TURN row may rewrite the durable workspace semantic digest and clear the
|
|
1624
|
+
// reanchor flag. We use the public startTask path with a renamed visible
|
|
1625
|
+
// hotel snapshot (the legitimate reanchor), then close/reopen and assert
|
|
1626
|
+
// the reconstructed state.
|
|
1627
|
+
{
|
|
1628
|
+
const root = f1MakeRoot('gotry-booking-v2-f1-replay-upgrade-');
|
|
1629
|
+
let ledger = f1Open(root);
|
|
1630
|
+
const taskId = 'task-f1-replay-upgrade-anchor';
|
|
1631
|
+
const baseline = {
|
|
1632
|
+
...workspace(0),
|
|
1633
|
+
visibleHotels: [{ hotelRef: 'hotel-f1', name: 'F1 Hotel', factRefs: [] }],
|
|
1634
|
+
loadedOffers: [loadedOffer('offer-f1', 'hotel-f1')],
|
|
1635
|
+
shortlistedOfferRefs: ['offer-f1'], selectedOfferRef: 'offer-f1',
|
|
1636
|
+
};
|
|
1637
|
+
const writer = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1638
|
+
writer.startTask({ ...turn(taskId), workspace: baseline });
|
|
1639
|
+
const check = writer.issueOperation(taskId, { ...action('f1-replay-check'), kind: 'offer.check', input: { offerRef: 'offer-f1', offerVersionRef: 'offer-f1:v1' } });
|
|
1640
|
+
// The receipt's blocker lineage must trigger replanAfterReplayUpgradeGap on
|
|
1641
|
+
// replay. We follow the existing legacyBlockerRoot pattern: write a normal
|
|
1642
|
+
// receipt with checkedOfferVersionRef, then strip the version field with
|
|
1643
|
+
// rewriteLegacyOfferRows so normalizeReceiptForReplay sees a legacy shape
|
|
1644
|
+
// and the digest mismatch fires the replan.
|
|
1645
|
+
const blockerReceipt = writer.withReceiptDigest({
|
|
1646
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: check.action.actionId, contextRef: 'ctx-v2',
|
|
1647
|
+
status: 'needs_input', revision: 1,
|
|
1648
|
+
observation: { kind: 'offer.availability', offerRef: 'offer-f1', checkedOfferVersionRef: 'offer-f1:v1', available: false, changedFactRefs: [], gapCodes: [] },
|
|
1649
|
+
resultContract: { outcome: 'partial', hardCriteriaMet: false, factRefs: ['fact-f1'], gapCodes: ['criterion_must_not_met'],
|
|
1650
|
+
blockers: [{ blockerId: 'f1-replay-blocker', sourceActionId: check.action.actionId, sourceReceiptDigest: '', scope: 'availability', code: 'criterion_must_not_met', criterionPath: 'offers.freeCancellation', strength: 'must', valueDigest: 'c'.repeat(64), evidence: { factRefs: ['fact-f1'], gapCodes: ['criterion_must_not_met'] } }],
|
|
1651
|
+
relaxationsApplied: [] },
|
|
1652
|
+
});
|
|
1653
|
+
writer.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId, workspace: { ...baseline, revision: 1 }, receipt: blockerReceipt });
|
|
1654
|
+
rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai');
|
|
1655
|
+
f1Close(ledger);
|
|
1656
|
+
// First resume: replan sets replayUpgradeRequired (the established
|
|
1657
|
+
// pattern for setting up replayUpgradeRequired; root public old-public
|
|
1658
|
+
// probe independently proved origin).
|
|
1659
|
+
const reopenedLedger = f1Open(root);
|
|
1660
|
+
const reader = new BookingCopilotTaskRuntime(reopenedLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
1661
|
+
const recovered = reader.resumeTask(taskId);
|
|
1662
|
+
assert.equal(recovered?.replayUpgradeRequired, true, 'legacy blocker lineage replan sets replayUpgradeRequired');
|
|
1663
|
+
f1Close(reopenedLedger);
|
|
1664
|
+
// Apply the legitimate reanchor via PUBLIC startTask while
|
|
1665
|
+
// replayUpgradeRequired is set; the reanchor TURN row rewrites the
|
|
1666
|
+
// durable semantic digest and clears the flag.
|
|
1667
|
+
ledger = f1Open(root);
|
|
1668
|
+
const reanchorWriter = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1669
|
+
const reanchorWorkspace = { ...baseline, revision: 1, visibleHotels: [{ hotelRef: 'hotel-f1', name: 'F1 Reanchored Hotel', factRefs: [] }] };
|
|
1670
|
+
reanchorWriter.startTask({ ...turn(taskId, 1), turnId: 'f1-replay-reanchor-turn', workspace: reanchorWorkspace, request: { text: 're-anchor after blocker' } });
|
|
1671
|
+
f1Close(ledger);
|
|
1672
|
+
const finalLedger = f1Open(root);
|
|
1673
|
+
const finalReader = new BookingCopilotTaskRuntime(finalLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
1674
|
+
const upgraded = finalReader.resumeTask(taskId);
|
|
1675
|
+
assert.equal(upgraded?.workspaceSnapshot?.visibleHotels?.[0]?.name, 'F1 Reanchored Hotel', 'replayUpgradeRequired allows the same-rev reanchor drift via public startTask');
|
|
1676
|
+
assert.equal(upgraded?.replayUpgradeRequired, undefined, 'validated reanchor TURN clears replayUpgradeRequired');
|
|
1677
|
+
f1Close(finalLedger);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
finally {
|
|
1681
|
+
const closeErrors = [];
|
|
1682
|
+
for (const open of [...f1Ledgers]) {
|
|
1683
|
+
try {
|
|
1684
|
+
open.close();
|
|
1685
|
+
}
|
|
1686
|
+
catch (e) {
|
|
1687
|
+
closeErrors.push(e);
|
|
1688
|
+
}
|
|
1689
|
+
f1Ledgers.delete(open);
|
|
1690
|
+
}
|
|
1691
|
+
for (const root of [...f1Roots]) {
|
|
1692
|
+
try {
|
|
1693
|
+
rmSync(root, { recursive: true, force: true });
|
|
1694
|
+
}
|
|
1695
|
+
catch (e) {
|
|
1696
|
+
closeErrors.push(e);
|
|
1697
|
+
}
|
|
1698
|
+
f1Roots.delete(root);
|
|
1699
|
+
}
|
|
1700
|
+
if (closeErrors.length)
|
|
1701
|
+
throw new AggregateError(closeErrors, 'f1 fixture cleanup failed');
|
|
1702
|
+
}
|
|
1703
|
+
const legacyOfferRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-offer-version-'));
|
|
1704
|
+
const legacyOfferLedger = ensureLedger(legacyOfferRoot);
|
|
1705
|
+
const legacyOfferRuntime = new BookingCopilotTaskRuntime(legacyOfferLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
1706
|
+
const legacyWorkspace0 = { ...workspace(0), visibleHotels: [{ hotelRef: 'legacy-hotel', name: 'Legacy Hotel', factRefs: [] }], loadedOffers: [loadedOffer('legacy-offer-a', 'legacy-hotel'), loadedOffer('legacy-offer-b', 'legacy-hotel')], selectedOfferRef: 'legacy-offer-a', shortlistedOfferRefs: ['legacy-offer-a', 'legacy-offer-b'] };
|
|
1707
|
+
const legacyWorkspace1 = { ...legacyWorkspace0, revision: 1, loadedOffers: [{ ...loadedOffer('legacy-offer-a', 'legacy-hotel', 'legacy-offer-a:v2'), factRefs: ['price'] }, loadedOffer('legacy-offer-b', 'legacy-hotel')] };
|
|
1708
|
+
const legacyOfferTask = legacyOfferRuntime.startTask({ ...turn('task-legacy-offer-version'), workspace: legacyWorkspace0 });
|
|
1709
|
+
legacyOfferRuntime.issueOperation(legacyOfferTask.taskId, { ...action('legacy-offer-check-v1'), kind: 'offer.check', input: { offerRef: 'legacy-offer-a', offerVersionRef: 'legacy-offer-a:v1' } });
|
|
1710
|
+
legacyOfferRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: legacyOfferTask.taskId, workspace: legacyWorkspace1, receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'legacy-offer-check-v1', contextRef: 'ctx-v2', status: 'changed', revision: 1, observation: { kind: 'offer.availability', offerRef: 'legacy-offer-a', checkedOfferVersionRef: 'legacy-offer-a:v1', currentOfferVersionRef: 'legacy-offer-a:v2', available: true, changedFactRefs: ['price'], gapCodes: [] }, resultContract: { outcome: 'partial', hardCriteriaMet: false, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } } });
|
|
1711
|
+
legacyOfferRuntime.startTask({ ...turn('task-legacy-offer-version', 1), workspace: legacyWorkspace1, turnId: 'task-legacy-offer-version-turn-1' });
|
|
1712
|
+
legacyOfferRuntime.issueOperation(legacyOfferTask.taskId, { ...action('legacy-offer-check-v2', 1), kind: 'offer.check', input: { offerRef: 'legacy-offer-a', offerVersionRef: 'legacy-offer-a:v2' } });
|
|
1713
|
+
rewriteLegacyOfferRows(legacyOfferLedger, legacyOfferTask.taskId, 'find hotels in Dubai');
|
|
1714
|
+
const legacyOfferRestart = new BookingCopilotTaskRuntime(ensureLedger(legacyOfferRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
1715
|
+
const legacyOfferRecovered = legacyOfferRestart.resumeTask(legacyOfferTask.taskId);
|
|
1716
|
+
assert.equal(legacyOfferRecovered?.pendingAction, undefined, 'pre-intent offer actions never resume as executable pending work');
|
|
1717
|
+
assert.equal(legacyOfferRecovered?.activeIntent, undefined, 'legacy offer rows do not infer a narrowed user goal');
|
|
1718
|
+
assert.equal(legacyOfferRecovered?.lastReceipt, undefined, 'legacy offer receipts cannot become terminal evidence without durable intent');
|
|
1719
|
+
assert.equal(legacyOfferRecovered?.replayUpgradeRequired, true, 'pre-intent offer rows require a fresh user-turn reanchor');
|
|
1720
|
+
legacyOfferRestart['ledger'].close();
|
|
1721
|
+
legacyOfferLedger.close();
|
|
1722
|
+
rmSync(legacyOfferRoot, { recursive: true, force: true });
|
|
1723
|
+
// An unsafe legacy checkout tail must not resurrect a scalar verifiedOfferRef
|
|
1724
|
+
// or strand the task behind terminal/error events written by the old runtime.
|
|
1725
|
+
// The upgrader skips that tail, preserves ordinals/sequences, then permits a
|
|
1726
|
+
// new typed turn and a new version-bound check to append after restart.
|
|
1727
|
+
const legacyCheckoutRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-checkout-tail-'));
|
|
1728
|
+
const legacyCheckoutLedger = ensureLedger(legacyCheckoutRoot);
|
|
1729
|
+
const legacyCheckoutRuntime = new BookingCopilotTaskRuntime(legacyCheckoutLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1730
|
+
const legacyCheckoutCapability = verifiedCapability('legacy-checkout', 'legacy-checkout:v1');
|
|
1731
|
+
const legacyCheckoutWorkspace = { ...workspace(1), visibleHotels: [{ hotelRef: 'legacy-checkout-hotel', name: 'Legacy Checkout Hotel', factRefs: [] }], loadedOffers: [loadedOffer('legacy-checkout', 'legacy-checkout-hotel')], selectedOfferRef: 'legacy-checkout', verifiedOffer: legacyCheckoutCapability };
|
|
1732
|
+
const legacyCheckoutTask = legacyCheckoutRuntime.startTask({ ...turn('task-legacy-checkout-tail'), workspace: legacyCheckoutWorkspace });
|
|
1733
|
+
const legacyCheckoutAction = { ...action('legacy-checkout-action', 1), kind: 'checkout.prepare', input: { offerRef: 'legacy-checkout', offerVersionRef: 'legacy-checkout:v1', verifiedOfferRef: legacyCheckoutCapability.verifiedOfferRef } };
|
|
1734
|
+
const legacyCheckoutBatch = legacyCheckoutRuntime.applyDecisionBatch(legacyCheckoutTask.taskId, 'legacy-checkout-batch', [{ kind: 'operation', action: legacyCheckoutAction, intent: checkoutPreparedIntent }, { kind: 'explanation', explanation: { text: 'checkout handoff is awaiting receipt', factRefs: [] } }], true);
|
|
1735
|
+
assert.equal(legacyCheckoutBatch.some((event) => event.kind === 'operation' && event.action.actionId === legacyCheckoutAction.actionId), true, 'normal decision batch includes the checkout operation envelope');
|
|
1736
|
+
const appendLegacyEvent = (eventId, sequence, eventKind) => {
|
|
1737
|
+
const safeContent = eventKind === 'terminal' ? { status: 'stopped', summary: 'legacy-terminal', factRefs: [] } : { code: 'unhandled', message: 'legacy error', retryable: false };
|
|
1738
|
+
legacyCheckoutLedger.insertEvent({ actor: 'system:booking-copilot', kind: 'booking.copilot.event.emitted', subjectId: legacyCheckoutTask.taskId, runId: legacyCheckoutTask.taskId, ts: '2026-09-01T10:00:01.000Z', idemKey: `legacy-tail:${eventId}`, payload: { schema: 'booking.copilot.ledger', taskId: legacyCheckoutTask.taskId, contextRef: 'ctx-v2', eventId, sequence, emittedAt: '2026-09-01T10:00:01.000Z', eventKind, contentDigest: bookingDigest(safeContent) } });
|
|
1739
|
+
};
|
|
1740
|
+
appendLegacyEvent('legacy-terminal-event', 6, 'terminal');
|
|
1741
|
+
appendLegacyEvent('legacy-error-event', 7, 'error');
|
|
1742
|
+
rewriteLegacyOfferRows(legacyCheckoutLedger, legacyCheckoutTask.taskId, 'find hotels in Dubai', legacyCheckoutCapability.verifiedOfferRef);
|
|
1743
|
+
const legacyCheckoutNextWorkspace = { ...legacyCheckoutWorkspace, revision: 2, verifiedOffer: undefined };
|
|
1744
|
+
legacyCheckoutRuntime.appendTurn(legacyCheckoutTask.taskId, 'ctx-v2', bookingDigest({ schemaVersion: 'booking.surface', kind: 'user.turn', taskId: legacyCheckoutTask.taskId, turnId: 'legacy-checkout-next-turn', workspace: legacyCheckoutNextWorkspace, request: { text: 'check the replacement room' } }), oldWorkspaceDigest(legacyCheckoutNextWorkspace), oldWorkspaceSemanticDigest(legacyCheckoutNextWorkspace), 2, 'legacy-checkout-next-turn', legacyCheckoutNextWorkspace);
|
|
1745
|
+
const legacyCheckoutRestart = new BookingCopilotTaskRuntime(ensureLedger(legacyCheckoutRoot), { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1746
|
+
const legacyCheckoutRecovery = legacyCheckoutRestart.resumeTask(legacyCheckoutTask.taskId);
|
|
1747
|
+
assert.equal(legacyCheckoutRecovery?.workspaceSnapshot?.verifiedOffer, undefined, 'legacy scalar verifiedOfferRef never becomes a live capability');
|
|
1748
|
+
assert.equal(legacyCheckoutRecovery?.phase, 'planning', 'unsafe legacy checkout tail suppresses old terminal/error events and replans');
|
|
1749
|
+
assert.equal(legacyCheckoutRecovery?.operationCount, 1, 'legacy checkout action ordinal remains consumed after tail skip');
|
|
1750
|
+
assert.ok((legacyCheckoutRecovery?.lastSequence ?? 0) >= 7, 'legacy terminal/error sequence remains consumed for append-only continuation');
|
|
1751
|
+
legacyCheckoutRestart.issueOperation(legacyCheckoutTask.taskId, { ...action('legacy-checkout-recheck', 2), kind: 'offer.check', input: { offerRef: 'legacy-checkout', offerVersionRef: 'legacy-checkout:v1' } });
|
|
1752
|
+
const legacyCheckoutAfterAppend = legacyCheckoutRestart.resumeTask(legacyCheckoutTask.taskId);
|
|
1753
|
+
assert.equal(legacyCheckoutAfterAppend?.phase, 'waiting_receipt', 'new typed offer.check remains reachable after unsafe legacy tail');
|
|
1754
|
+
assert.equal(legacyCheckoutAfterAppend?.operationCount, 2);
|
|
1755
|
+
assert.equal(legacyCheckoutAfterAppend?.revision, 2);
|
|
1756
|
+
const legacyCheckoutNewEvent = legacyCheckoutRestart.emitEvent(legacyCheckoutTask.taskId, { kind: 'explanation', explanation: { text: 'new anchor event', factRefs: [] } });
|
|
1757
|
+
assert.equal(legacyCheckoutNewEvent.sequence, (legacyCheckoutAfterAppend?.lastSequence ?? 0) + 1, 'new post-reanchor event receives the next unique sequence');
|
|
1758
|
+
legacyCheckoutRestart['ledger'].close();
|
|
1759
|
+
legacyCheckoutLedger.close();
|
|
1760
|
+
rmSync(legacyCheckoutRoot, { recursive: true, force: true });
|
|
1761
|
+
const legacyBatchVariantFixture = (variant) => {
|
|
1762
|
+
const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-batch-${variant}-`));
|
|
1763
|
+
const ledger = ensureLedger(root);
|
|
1764
|
+
const writer = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1765
|
+
const taskId = `task-legacy-batch-${variant}`;
|
|
1766
|
+
const capability = verifiedCapability(`batch-${variant}-offer`);
|
|
1767
|
+
const batchWorkspace = { ...workspace(0), visibleHotels: [{ hotelRef: `batch-${variant}-hotel`, name: 'Batch Hotel', factRefs: [] }], loadedOffers: [loadedOffer(`batch-${variant}-offer`, `batch-${variant}-hotel`)], selectedOfferRef: `batch-${variant}-offer`, verifiedOffer: capability };
|
|
1768
|
+
writer.startTask({ ...turn(taskId), workspace: batchWorkspace });
|
|
1769
|
+
const batchAction = { ...action(`batch-${variant}-checkout`), kind: 'checkout.prepare', input: { offerRef: `batch-${variant}-offer`, offerVersionRef: `batch-${variant}-offer:v1`, verifiedOfferRef: capability.verifiedOfferRef } };
|
|
1770
|
+
writer.applyDecisionBatch(taskId, `batch-envelope-${variant}`, [{ kind: 'operation', action: batchAction, intent: checkoutPreparedIntent }, { kind: 'explanation', explanation: { text: 'batch envelope', factRefs: [] } }], true);
|
|
1771
|
+
rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai', capability.verifiedOfferRef);
|
|
1772
|
+
const row = ledger.db.prepare("SELECT seq, payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.decision.batch'").get(taskId);
|
|
1773
|
+
const payload = JSON.parse(row.payload);
|
|
1774
|
+
if (variant === 'tampered') {
|
|
1775
|
+
const explanation = payload.events.find((event) => event.kind === 'explanation');
|
|
1776
|
+
if (explanation && explanation.explanation && typeof explanation.explanation === 'object')
|
|
1777
|
+
explanation.explanation.text = 'edited envelope';
|
|
1778
|
+
}
|
|
1779
|
+
else if (variant === 'missing') {
|
|
1780
|
+
const operationIndex = payload.events.findIndex((event) => event.kind === 'operation');
|
|
1781
|
+
assert.ok(operationIndex >= 0, 'missing batch fixture has a retained operation identity to remove');
|
|
1782
|
+
payload.events.splice(operationIndex, 1);
|
|
1783
|
+
}
|
|
1784
|
+
else if (variant === 'unknown') {
|
|
1785
|
+
const first = payload.events[0];
|
|
1786
|
+
if (first)
|
|
1787
|
+
first.eventId = 'unknown-envelope-event';
|
|
1788
|
+
}
|
|
1789
|
+
else {
|
|
1790
|
+
const first = payload.events[0];
|
|
1791
|
+
if (first)
|
|
1792
|
+
payload.events.push(structuredClone(first));
|
|
1793
|
+
}
|
|
1794
|
+
ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
|
|
1795
|
+
ledger.close();
|
|
1796
|
+
return { root, taskId };
|
|
1797
|
+
};
|
|
1798
|
+
for (const variant of ['tampered', 'missing', 'unknown', 'duplicate']) {
|
|
1799
|
+
const fixtureState = legacyBatchVariantFixture(variant);
|
|
1800
|
+
const replayLedger = ensureLedger(fixtureState.root);
|
|
1801
|
+
const replay = new BookingCopilotTaskRuntime(replayLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1802
|
+
assert.throws(() => replay.resumeTask(fixtureState.taskId), new RegExp(`ledger_corrupt:${fixtureState.taskId}:decision_batch`), `legacy duplicate batch ${variant} mismatch fails closed`);
|
|
1803
|
+
replayLedger.close();
|
|
1804
|
+
rmSync(fixtureState.root, { recursive: true, force: true });
|
|
1805
|
+
}
|
|
1806
|
+
// Batch identity is scoped to its own envelope. A standalone EVENT/ACTION
|
|
1807
|
+
// pair is completed before a normal batch, then a second unsafe legacy batch
|
|
1808
|
+
// opens the migration skip tail. Replay must consume the first batch normally,
|
|
1809
|
+
// validate the second against only its own individual rows, and reject a
|
|
1810
|
+
// cross-batch event graft instead of accepting a globally consumed identity.
|
|
1811
|
+
const legacyTwoBatchFixture = (crossBatchGraft) => {
|
|
1812
|
+
const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-two-batch-${crossBatchGraft ? 'graft' : 'clean'}-`));
|
|
1813
|
+
const ledger = ensureLedger(root);
|
|
1814
|
+
const writer = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1815
|
+
const taskId = `task-legacy-two-batch-${crossBatchGraft ? 'graft' : 'clean'}`;
|
|
1816
|
+
const capability = verifiedCapability(`${taskId}-offer`);
|
|
1817
|
+
const initialWorkspace = {
|
|
1818
|
+
...workspace(0), visibleHotels: [{ hotelRef: `${taskId}-hotel`, name: 'Two Batch Hotel', factRefs: [] }],
|
|
1819
|
+
loadedOffers: [loadedOffer(`${taskId}-offer`, `${taskId}-hotel`)], focusedHotelRef: `${taskId}-hotel`, selectedOfferRef: `${taskId}-offer`, verifiedOffer: capability,
|
|
1820
|
+
};
|
|
1821
|
+
writer.startTask({ ...turn(taskId), workspace: initialWorkspace });
|
|
1822
|
+
const standaloneStatus = writer.emitEvent(taskId, { kind: 'status', status: 'submitted' });
|
|
1823
|
+
const standaloneAction = writer.issueOperation(taskId, { ...action(`${taskId}-standalone-action`), kind: 'hotel.focus', input: { hotelRef: `${taskId}-hotel` } });
|
|
1824
|
+
const standaloneReceipt = writer.withReceiptDigest({
|
|
1825
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: standaloneAction.action.actionId, contextRef: 'ctx-v2', status: 'applied', revision: 1,
|
|
1826
|
+
observation: { kind: 'hotel.focus', hotelRef: `${taskId}-hotel` }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] },
|
|
1827
|
+
});
|
|
1828
|
+
writer.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId, workspace: { ...initialWorkspace, revision: 1 }, receipt: standaloneReceipt });
|
|
1829
|
+
const batchWorkspace = {
|
|
1830
|
+
...workspace(1), visibleHotels: [{ hotelRef: `${taskId}-hotel`, name: 'Two Batch Hotel', factRefs: [] }],
|
|
1831
|
+
loadedOffers: [loadedOffer(`${taskId}-offer`, `${taskId}-hotel`)], focusedHotelRef: `${taskId}-hotel`, selectedOfferRef: `${taskId}-offer`, verifiedOffer: capability,
|
|
1832
|
+
};
|
|
1833
|
+
writer.startTask({ ...turn(taskId, 1), turnId: `${taskId}-anchor-turn`, workspace: batchWorkspace });
|
|
1834
|
+
const normalBatch = writer.applyDecisionBatch(taskId, `${taskId}-normal-batch`, [{ kind: 'explanation', explanation: { text: 'normal completed batch', factRefs: [] } }], true);
|
|
1835
|
+
assert.ok(normalBatch.some((event) => event.kind === 'explanation'), 'normal batch persists its own explanation');
|
|
1836
|
+
const unsafeAction = { ...action(`${taskId}-unsafe-checkout`, 1), kind: 'checkout.prepare', input: { offerRef: `${taskId}-offer`, offerVersionRef: `${taskId}-offer:v1`, verifiedOfferRef: capability.verifiedOfferRef } };
|
|
1837
|
+
writer.applyDecisionBatch(taskId, `${taskId}-unsafe-batch`, [{ kind: 'operation', action: unsafeAction, intent: checkoutPreparedIntent }, { kind: 'explanation', explanation: { text: 'unsafe legacy batch', factRefs: [] } }], true);
|
|
1838
|
+
rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai', capability.verifiedOfferRef);
|
|
1839
|
+
if (crossBatchGraft) {
|
|
1840
|
+
const rows = ledger.db.prepare("SELECT seq, payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.decision.batch' ORDER BY seq").all(taskId);
|
|
1841
|
+
assert.equal(rows.length, 2, 'two-batch fixture retains both decision envelopes');
|
|
1842
|
+
const firstPayload = JSON.parse(rows[0].payload);
|
|
1843
|
+
const secondPayload = JSON.parse(rows[1].payload);
|
|
1844
|
+
const graft = firstPayload.events.find((event) => event.kind === 'explanation');
|
|
1845
|
+
assert.ok(graft, 'first batch has an event identity available for graft detection');
|
|
1846
|
+
const graftIndex = secondPayload.events.findIndex((event) => event.kind === 'explanation');
|
|
1847
|
+
assert.ok(graftIndex >= 0, 'second batch has a retained event slot for graft detection');
|
|
1848
|
+
secondPayload.events[graftIndex] = structuredClone(graft);
|
|
1849
|
+
ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(secondPayload), rows[1].seq);
|
|
1850
|
+
}
|
|
1851
|
+
assert.equal(standaloneStatus.sequence < standaloneAction.sequence, true, 'standalone EVENT precedes standalone ACTION before batched work');
|
|
1852
|
+
ledger.close();
|
|
1853
|
+
return { root, taskId };
|
|
1854
|
+
};
|
|
1855
|
+
const cleanTwoBatch = legacyTwoBatchFixture(false);
|
|
1856
|
+
const cleanTwoBatchReplayLedger = ensureLedger(cleanTwoBatch.root);
|
|
1857
|
+
const cleanTwoBatchReplay = new BookingCopilotTaskRuntime(cleanTwoBatchReplayLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1858
|
+
const cleanTwoBatchState = cleanTwoBatchReplay.resumeTask(cleanTwoBatch.taskId);
|
|
1859
|
+
assert.equal(cleanTwoBatchState?.phase, 'planning', 'normal batch replays before unsafe legacy batch enters replan');
|
|
1860
|
+
assert.equal(cleanTwoBatchState?.operationCount, 2, 'standalone action and unsafe batch action ordinals remain consumed');
|
|
1861
|
+
cleanTwoBatchReplayLedger.close();
|
|
1862
|
+
rmSync(cleanTwoBatch.root, { recursive: true, force: true });
|
|
1863
|
+
const graftedTwoBatch = legacyTwoBatchFixture(true);
|
|
1864
|
+
const graftedTwoBatchReplayLedger = ensureLedger(graftedTwoBatch.root);
|
|
1865
|
+
const graftedTwoBatchReplay = new BookingCopilotTaskRuntime(graftedTwoBatchReplayLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1866
|
+
assert.throws(() => graftedTwoBatchReplay.resumeTask(graftedTwoBatch.taskId), new RegExp(`ledger_corrupt:${graftedTwoBatch.taskId}:decision_batch`), 'cross-batch event graft fails closed within the second envelope');
|
|
1867
|
+
graftedTwoBatchReplayLedger.close();
|
|
1868
|
+
rmSync(graftedTwoBatch.root, { recursive: true, force: true });
|
|
1869
|
+
// Even a structurally safe pre-versioned offer.select/offer.check batch lacks
|
|
1870
|
+
// the user's final waypoint. It remains readable as history, but the runtime
|
|
1871
|
+
// must not resume or replay it as executable work by inventing an intent.
|
|
1872
|
+
for (const kind of ['offer.select', 'offer.check']) {
|
|
1873
|
+
const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-safe-batch-${kind.replace('.', '-')}-`));
|
|
1874
|
+
const ledger = ensureLedger(root);
|
|
1875
|
+
const writer = new BookingCopilotTaskRuntime(ledger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1876
|
+
const taskId = `task-legacy-safe-batch-${kind.replace('.', '-')}`;
|
|
1877
|
+
const offerRef = `${taskId}-offer`;
|
|
1878
|
+
const hotelRef = `${taskId}-hotel`;
|
|
1879
|
+
const safeWorkspace = { ...workspace(0), visibleHotels: [{ hotelRef, name: 'Safe Batch Hotel', factRefs: [] }], loadedOffers: [loadedOffer(offerRef, hotelRef)] };
|
|
1880
|
+
writer.startTask({ ...turn(taskId), workspace: safeWorkspace });
|
|
1881
|
+
const safeAction = { ...action(`${taskId}-action`), kind, input: { offerRef, offerVersionRef: `${offerRef}:v1` } };
|
|
1882
|
+
const requestKey = `${taskId}-request`;
|
|
1883
|
+
const safeIntent = kind === 'offer.select'
|
|
1884
|
+
? { schemaVersion: 'booking.intent.v1', target: 'offer.selected' }
|
|
1885
|
+
: offerVerifiedIntent;
|
|
1886
|
+
writer.applyDecisionBatch(taskId, requestKey, [{ kind: 'operation', action: safeAction, intent: safeIntent }], true);
|
|
1887
|
+
rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai');
|
|
1888
|
+
ledger.close();
|
|
1889
|
+
const restartLedger = ensureLedger(root);
|
|
1890
|
+
const restart = new BookingCopilotTaskRuntime(restartLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1891
|
+
const recovered = restart.resumeTask(taskId);
|
|
1892
|
+
assert.equal(recovered?.pendingAction, undefined, `legacy ${kind} batch is not resumed without durable intent`);
|
|
1893
|
+
assert.equal(recovered?.activeIntent, undefined, `legacy ${kind} batch does not infer its final waypoint`);
|
|
1894
|
+
assert.equal(recovered?.replayUpgradeRequired, true, `legacy ${kind} batch requires a fresh user-turn anchor`);
|
|
1895
|
+
assert.throws(() => restart.readDecisionBatch(taskId, requestKey), /request_key_invalidated/, `legacy ${kind} executable batch replay is invalidated`);
|
|
1896
|
+
restartLedger.close();
|
|
1897
|
+
rmSync(root, { recursive: true, force: true });
|
|
1898
|
+
}
|
|
1899
|
+
// A legacy receipt whose normalization changes a blocker/approval lineage is
|
|
1900
|
+
// unsafe to replay: it must enter an explicit replan gap, never be mislabeled
|
|
1901
|
+
// as a corrupt ledger or silently recreate approval authority.
|
|
1902
|
+
const legacyBlockerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-blocker-replan-'));
|
|
1903
|
+
const legacyBlockerLedger = ensureLedger(legacyBlockerRoot);
|
|
1904
|
+
const legacyBlockerRuntime = new BookingCopilotTaskRuntime(legacyBlockerLedger, { contextRefFactory: () => 'ctx-v2' });
|
|
1905
|
+
const blockerWorkspace0 = { ...workspace(0), visibleHotels: [{ hotelRef: 'blocker-hotel', name: 'Blocker Hotel', factRefs: [] }], loadedOffers: [loadedOffer('blocker-offer', 'blocker-hotel')] };
|
|
1906
|
+
const blockerTask = legacyBlockerRuntime.startTask({ ...turn('task-legacy-blocker-lineage'), workspace: blockerWorkspace0 });
|
|
1907
|
+
legacyBlockerRuntime.issueOperation(blockerTask.taskId, { ...action('legacy-blocker-check'), kind: 'offer.check', input: { offerRef: 'blocker-offer', offerVersionRef: 'blocker-offer:v1' } });
|
|
1908
|
+
const legacyLineageReceipt = legacyBlockerRuntime.withReceiptDigest({ schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'legacy-blocker-check', contextRef: 'ctx-v2', status: 'needs_input', revision: 1, observation: { kind: 'offer.availability', offerRef: 'blocker-offer', checkedOfferVersionRef: 'blocker-offer:v1', available: false, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'partial', hardCriteriaMet: false, factRefs: ['fact-free-cancellation'], gapCodes: ['criterion_must_not_met'], blockers: [{ blockerId: 'legacy-blocker', sourceActionId: 'legacy-blocker-check', sourceReceiptDigest: '', scope: 'availability', code: 'criterion_must_not_met', criterionPath: 'offers.freeCancellation', strength: 'must', valueDigest: 'c'.repeat(64), evidence: { factRefs: ['fact-free-cancellation'], gapCodes: ['criterion_must_not_met'] } }], relaxationsApplied: [] } });
|
|
1909
|
+
legacyBlockerRuntime.continueWithReceipt({ schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: blockerTask.taskId, workspace: { ...blockerWorkspace0, revision: 1 }, receipt: legacyLineageReceipt });
|
|
1910
|
+
rewriteLegacyOfferRows(legacyBlockerLedger, blockerTask.taskId, 'find hotels in Dubai');
|
|
1911
|
+
const legacyBlockerRestart = new BookingCopilotTaskRuntime(ensureLedger(legacyBlockerRoot), { contextRefFactory: () => 'ctx-v2' });
|
|
1912
|
+
const blockerRecovery = legacyBlockerRestart.resumeTask(blockerTask.taskId);
|
|
1913
|
+
assert.equal(blockerRecovery?.phase, 'planning', 'normalized legacy blocker receipt fail-closes to replan');
|
|
1914
|
+
assert.equal(blockerRecovery?.replayUpgradeRequired, true, 'legacy blocker lineage records an explicit replay upgrade gap');
|
|
1915
|
+
assert.equal(blockerRecovery?.pendingAction, undefined);
|
|
1916
|
+
assert.throws(() => legacyBlockerRestart.issueOperation(blockerTask.taskId, action('blocked-replan-action', 1)), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks direct operation before a new anchor turn');
|
|
1917
|
+
assert.throws(() => legacyBlockerRestart.applyDecisionBatch(blockerTask.taskId, 'blocked-replan-batch', [{ kind: 'operation', action: action('blocked-replan-decision', 1), intent: searchResultsIntent }]), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks decision batches before a new anchor turn');
|
|
1918
|
+
assert.throws(() => legacyBlockerRestart.emitEvent(blockerTask.taskId, { kind: 'explanation', explanation: { text: 'must wait for a new anchor turn', factRefs: [] } }), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks standalone event mutation');
|
|
1919
|
+
const blockedBindingTurn = { ...turn(blockerTask.taskId, 1), turnId: 'blocked-replan-binding-turn' };
|
|
1920
|
+
assert.throws(() => legacyBlockerRestart.persistRequestBinding('blocked-replan-request', blockedBindingTurn, { requestKey: 'blocked-replan-request', principal: { subject: 'blocked-replan-principal', scope: 'booking:read' } }), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks request binding mutation');
|
|
1921
|
+
assert.throws(() => legacyBlockerRestart.terminalDecisionBatch(blockerTask.taskId, 'blocked-replan-terminal-batch'), /replay_upgrade|reanchor|replan|turn_required|task_not_terminal/, 'replay upgrade gap blocks terminal decision mutation');
|
|
1922
|
+
const reanchorWorkspace = blockerRecovery.workspaceSnapshot;
|
|
1923
|
+
const reanchor = legacyBlockerRestart.startTask({ ...turn(blockerTask.taskId, reanchorWorkspace.revision), turnId: 'legacy-blocker-reanchor-turn', workspace: reanchorWorkspace, request: { text: 're-anchor the upgraded workspace' } });
|
|
1924
|
+
assert.equal(reanchor.replayUpgradeRequired, undefined, 'validated new TURN clears replay upgrade gap');
|
|
1925
|
+
legacyBlockerRestart['ledger'].close();
|
|
1926
|
+
legacyBlockerLedger.close();
|
|
1927
|
+
rmSync(legacyBlockerRoot, { recursive: true, force: true });
|
|
1928
|
+
// Once that legitimate checkout upgrade gap activates tail skipping, a later
|
|
1929
|
+
// skipped ACTION is still validated. Inflated ordinals may not be hidden by
|
|
1930
|
+
// Math.max while the runtime consumes the old tail.
|
|
1931
|
+
const skippedActionRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-skipped-action-tamper-'));
|
|
1932
|
+
const skippedActionLedger = ensureLedger(skippedActionRoot);
|
|
1933
|
+
const skippedActionWriter = new BookingCopilotTaskRuntime(skippedActionLedger, { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1934
|
+
const skippedWorkspace = { ...workspace(0), visibleHotels: [{ hotelRef: 'skipped-hotel', name: 'Skipped Hotel', factRefs: [] }], loadedOffers: [loadedOffer('skipped-offer', 'skipped-hotel')], selectedOfferRef: 'skipped-offer', verifiedOffer: verifiedCapability('skipped-offer') };
|
|
1935
|
+
const skippedTask = skippedActionWriter.startTask({ ...turn('task-legacy-skipped-action'), workspace: skippedWorkspace });
|
|
1936
|
+
skippedActionWriter.issueOperation(skippedTask.taskId, { ...action('unsafe-checkout-tail'), kind: 'checkout.prepare', input: { offerRef: 'skipped-offer', offerVersionRef: 'skipped-offer:v1', verifiedOfferRef: 'verified-skipped-offer' } });
|
|
1937
|
+
rewriteLegacyOfferRows(skippedActionLedger, skippedTask.taskId, 'find hotels in Dubai', 'verified-skipped-offer');
|
|
1938
|
+
skippedActionLedger.insertEvent({ actor: 'system:booking-copilot', kind: 'booking.copilot.event.emitted', subjectId: skippedTask.taskId, runId: skippedTask.taskId, ts: '2026-09-01T10:00:01.000Z', idemKey: 'skipped-action-tail-event', payload: { schema: 'booking.copilot.ledger', taskId: skippedTask.taskId, contextRef: 'ctx-v2', eventId: 'skipped-action-tail-event', sequence: 2, emittedAt: '2026-09-01T10:00:01.000Z', eventKind: 'error', contentDigest: bookingDigest({ code: 'unhandled', message: 'legacy error', retryable: false }) } });
|
|
1939
|
+
const lateActionBase = { actionId: 'legacy-skipped-action', kind: 'search.run', contextRef: 'ctx-v2', expectedRevision: 0, factRefs: [], reasonDigest: bookingDigest('legacy search'), inputDigest: bookingDigest({}), input: {}, eventId: 'legacy-skipped-operation', sequence: 3, emittedAt: '2026-09-01T10:00:02.000Z', sourceTurnId: 'task-legacy-skipped-action-turn-0' };
|
|
1940
|
+
const lateAction = { ...lateActionBase, actionDigest: bookingDigest(lateActionBase) };
|
|
1941
|
+
const startAvailability = JSON.parse(skippedActionLedger.db.prepare("SELECT payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.task.started'").get(skippedTask.taskId).payload).availability;
|
|
1942
|
+
skippedActionLedger.insertEvent({ actor: 'system:booking-copilot', kind: 'booking.copilot.action.issued', subjectId: skippedTask.taskId, runId: skippedTask.taskId, ts: '2026-09-01T10:00:02.000Z', idemKey: 'legacy-skipped-action-row', payload: { schema: 'booking.copilot.ledger', taskId: skippedTask.taskId, contextRef: 'ctx-v2', action: lateAction, operationCount: 99, availability: legacyAvailability(startAvailability), availabilityDigest: bookingDigest(legacyAvailability(startAvailability)) } });
|
|
1943
|
+
const skippedActionRestart = new BookingCopilotTaskRuntime(ensureLedger(skippedActionRoot), { contextRefFactory: () => 'ctx-v2', now: () => '2026-09-01T10:00:00.000Z' });
|
|
1944
|
+
assert.throws(() => skippedActionRestart.resumeTask(skippedTask.taskId), /ledger_corrupt:task-legacy-skipped-action:action/, 'tampered skipped ACTION ordinal fails closed during legacy tail replay');
|
|
1945
|
+
skippedActionRestart['ledger'].close();
|
|
1946
|
+
skippedActionLedger.close();
|
|
1947
|
+
rmSync(skippedActionRoot, { recursive: true, force: true });
|
|
1948
|
+
const approvedReplay = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(approvedIngress) });
|
|
1949
|
+
assert.equal(approvedReplay.status, 200);
|
|
1950
|
+
assert.equal(await approvedReplay.text(), approvedBody, 'approved ingress retry replays the durable batch while waiting for receipt');
|
|
1951
|
+
assert.equal(plannerCalls, 3, 'approved ingress replay does not call planner or append');
|
|
1952
|
+
const alteredTextReplay = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...approvedIngress, request: { ...approvedIngress.request, text: 'different request' } }) });
|
|
1953
|
+
assert.equal(alteredTextReplay.status, 409, 'altered request text cannot replay a waiting batch');
|
|
1954
|
+
const alteredContextReplay = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...approvedIngress, contextRef: 'ctx-forged' }) });
|
|
1955
|
+
assert.equal(alteredContextReplay.status, 400, 'browser context injection is rejected at ingress schema boundary');
|
|
1956
|
+
const alteredApprovalReplay = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...approvedIngress, request: { ...approvedIngress.request, approval: { ...approved, to: 'drop' } } }) });
|
|
1957
|
+
assert.equal(alteredApprovalReplay.status, 409, 'altered approval tuple cannot replay a waiting batch');
|
|
1958
|
+
const alteredCapabilitiesReplay = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...approvedIngress, workspace: { ...approvedIngress.workspace, capabilities: { surface: 'tenant', allowedActions: ['search.run', 'search.run'] } } }) });
|
|
1959
|
+
assert.equal(alteredCapabilitiesReplay.status, 400, 'browser capability injection is rejected at ingress schema boundary');
|
|
1960
|
+
const ingressReplay = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...ingress, workspace: { ...ingress.workspace, revision: 2 }, request: { text: 'prefer a nearby match' } }) });
|
|
1961
|
+
assert.equal(ingressReplay.status, 409, 'unbound ingress cannot replay an existing task');
|
|
1962
|
+
assert.equal(plannerCalls, 3);
|
|
1963
|
+
const staleHandshake = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer v2-server-key', 'content-type': 'application/json', 'x-booking-surface-version': 'booking.surface.retired', 'x-booking-surface-schema-sha256': '0'.repeat(64) }, body: JSON.stringify({ ...turn('task-server'), workspace: { ...workspace(1), contextRef: 'ctx-server' } }) });
|
|
1964
|
+
assert.equal(staleHandshake.status, 409, 'a retired handshake can no longer reach the single contract server');
|
|
1965
|
+
await server.close();
|
|
1966
|
+
{
|
|
1967
|
+
const checkoutServerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-http-checkout-after-check-'));
|
|
1968
|
+
const checkoutServerLedger = ensureLedger(checkoutServerRoot);
|
|
1969
|
+
const checkoutServerRuntime = new BookingCopilotTaskRuntime(checkoutServerLedger, { now: () => '2026-09-01T10:00:00.000Z' });
|
|
1970
|
+
let checkoutPlannerCalls = 0;
|
|
1971
|
+
let checkoutPlannerCloses = 0;
|
|
1972
|
+
let markCheckoutPlannerEntered;
|
|
1973
|
+
let releaseCheckoutPlanner;
|
|
1974
|
+
const checkoutPlannerEntered = new Promise((resolve) => { markCheckoutPlannerEntered = resolve; });
|
|
1975
|
+
const checkoutPlannerRelease = new Promise((resolve) => { releaseCheckoutPlanner = resolve; });
|
|
1976
|
+
const capability = verifiedCapability('server-offer', 'server-offer:v1', '2026-09-01T10:30:00.000Z');
|
|
1977
|
+
const checkoutFixtureAuth = ['checkout', 'after', 'check', 'key'].join('-');
|
|
1978
|
+
const checkoutServer = await startBookingCopilotServer({
|
|
1979
|
+
apiKey: checkoutFixtureAuth,
|
|
1980
|
+
runtime: checkoutServerRuntime,
|
|
1981
|
+
principal: { subject: 'bff-checkout-after-check', scope: 'booking:read' },
|
|
1982
|
+
ingressBinding: { bind: () => ({ taskId: 'task-http-checkout-after-check', turnId: 'checkout-after-check-turn', contextRef: 'ctx-checkout-after-check', surface: 'tenant', allowedActions: [...BOOKING_FULL_JOURNEY_ACTION_KINDS] }) },
|
|
1983
|
+
ingressMode: 'bff-ingress-binding',
|
|
1984
|
+
plannerFactory: () => ({
|
|
1985
|
+
next: async ({ task: current }) => {
|
|
1986
|
+
checkoutPlannerCalls++;
|
|
1987
|
+
if (checkoutPlannerCalls === 1) {
|
|
1988
|
+
return [{ kind: 'operation', action: { ...action('http-check-before-checkout', current.revision), contextRef: current.contextRef, kind: 'offer.check', input: { offerRef: 'server-offer', offerVersionRef: 'server-offer:v1' } }, intent: checkoutPreparedIntent }];
|
|
1989
|
+
}
|
|
1990
|
+
if (checkoutPlannerCalls === 2) {
|
|
1991
|
+
markCheckoutPlannerEntered();
|
|
1992
|
+
await checkoutPlannerRelease;
|
|
1993
|
+
}
|
|
1994
|
+
return [{ kind: 'operation', action: { ...action('http-checkout-after-check', current.revision), contextRef: current.contextRef, kind: 'checkout.prepare', input: { offerRef: 'server-offer', offerVersionRef: 'server-offer:v1', verifiedOfferRef: capability.verifiedOfferRef } }, intent: checkoutPreparedIntent }];
|
|
1995
|
+
},
|
|
1996
|
+
close: async () => { checkoutPlannerCloses += 1; },
|
|
1997
|
+
}),
|
|
1998
|
+
});
|
|
1999
|
+
const checkoutEndpoint = `http://127.0.0.1:${checkoutServer.port}/a2a/booking-copilot/turn`;
|
|
2000
|
+
const checkoutHeaders = { ...headers, [AUTHORIZATION_HEADER]: ['Bearer', checkoutFixtureAuth].join(' ') };
|
|
2001
|
+
const ingressWorkspace = { schemaVersion: 'booking.surface', revision: 0, locale: 'en-US', currency: 'AED', searchDraft: {}, results: { status: 'idle' }, visibleHotels: [{ hotelRef: 'server-hotel', name: 'Server Hotel', factRefs: [] }], loadedOffers: [loadedOffer('server-offer', 'server-hotel')], shortlistedOfferRefs: ['server-offer'], selectedOfferRef: 'server-offer' };
|
|
2002
|
+
const checkoutIngress = { ...ingress, requestKey: 'checkout-after-check-request-1', taskHandle: 'checkout-after-check-handle', workspace: ingressWorkspace, request: { text: 'check this room and continue to checkout' } };
|
|
2003
|
+
const parseEvents = (body) => body.trim().split('\n\n').filter(Boolean).map((frame) => {
|
|
2004
|
+
const data = frame.split('\n').find((line) => line.startsWith('data: '));
|
|
2005
|
+
assert.ok(data, `SSE frame has data: ${frame}`);
|
|
2006
|
+
return JSON.parse(data.slice('data: '.length));
|
|
2007
|
+
});
|
|
2008
|
+
const firstCheckoutResponse = await fetch(checkoutEndpoint, { method: 'POST', headers: checkoutHeaders, body: JSON.stringify(checkoutIngress) });
|
|
2009
|
+
assert.equal(firstCheckoutResponse.status, 200);
|
|
2010
|
+
const firstCheckoutEvents = parseEvents(await firstCheckoutResponse.text());
|
|
2011
|
+
const checkEvent = firstCheckoutEvents.find((event) => event.kind === 'operation');
|
|
2012
|
+
assert.equal(checkEvent?.action.kind, 'offer.check');
|
|
2013
|
+
const receiptWorkspace = { ...ingressWorkspace, contextRef: 'ctx-checkout-after-check', surface: 'tenant', revision: 1, capabilities: { surface: 'tenant', allowedActions: [...BOOKING_FULL_JOURNEY_ACTION_KINDS] }, verifiedOffer: capability };
|
|
2014
|
+
const checkoutReceiptTurn = {
|
|
2015
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: 'task-http-checkout-after-check', workspace: receiptWorkspace,
|
|
2016
|
+
receipt: { schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'http-check-before-checkout', contextRef: 'ctx-checkout-after-check', status: 'applied', revision: 1, observation: { kind: 'offer.availability', offerRef: 'server-offer', checkedOfferVersionRef: 'server-offer:v1', currentOfferVersionRef: 'server-offer:v1', verifiedOfferRef: capability.verifiedOfferRef, available: true, changedFactRefs: [], gapCodes: [] }, resultContract: { outcome: 'complete', hardCriteriaMet: true, factRefs: [], gapCodes: [], blockers: [], relaxationsApplied: [] } },
|
|
2017
|
+
};
|
|
2018
|
+
const checkoutContinuationPromise = fetch(checkoutEndpoint, { method: 'POST', headers: checkoutHeaders, body: JSON.stringify(checkoutReceiptTurn) }).then(async (response) => ({ status: response.status, body: await response.text() }));
|
|
2019
|
+
await checkoutPlannerEntered;
|
|
2020
|
+
const substitutedCapability = verifiedCapability('substituted-offer', 'substituted-offer:v1', '2026-09-01T10:30:00.000Z');
|
|
2021
|
+
const substitutedWorkspace = { ...receiptWorkspace, visibleHotels: [{ hotelRef: 'substituted-hotel', name: 'Substituted Hotel', factRefs: [] }], loadedOffers: [loadedOffer('substituted-offer', 'substituted-hotel')], shortlistedOfferRefs: ['substituted-offer'], selectedOfferRef: 'substituted-offer', verifiedOffer: substitutedCapability };
|
|
2022
|
+
const beforeSubstitutionRows = checkoutServerLedger.countEvents();
|
|
2023
|
+
const substitutedTurn = { schemaVersion: 'booking.surface', kind: 'user.turn', taskId: 'task-http-checkout-after-check', turnId: 'http-substitute-after-check', workspace: substitutedWorkspace, request: { text: 'continue with the substituted offer' } };
|
|
2024
|
+
const substitutedResponse = await fetch(checkoutEndpoint, { method: 'POST', headers: checkoutHeaders, body: JSON.stringify(substitutedTurn) });
|
|
2025
|
+
const substitutedStatus = substitutedResponse.status;
|
|
2026
|
+
const substitutedBody = await substitutedResponse.json();
|
|
2027
|
+
const afterSubstitutionRows = checkoutServerLedger.countEvents();
|
|
2028
|
+
releaseCheckoutPlanner();
|
|
2029
|
+
assert.equal(substitutedStatus, 409, 'a concurrent bound turn cannot swap the confirmed offer while checkout planning is in flight');
|
|
2030
|
+
assert.deepEqual(substitutedBody, { error: { code: 'WORKSPACE_MISMATCH' } });
|
|
2031
|
+
assert.equal(afterSubstitutionRows, beforeSubstitutionRows, 'rejected concurrent offer substitution appends no ledger rows');
|
|
2032
|
+
const checkoutContinuation = await checkoutContinuationPromise;
|
|
2033
|
+
assert.equal(checkoutContinuation.status, 200);
|
|
2034
|
+
const continuationEvents = parseEvents(checkoutContinuation.body);
|
|
2035
|
+
assert.equal(checkoutPlannerCalls, 2, 'confirmed offer.check receipt re-enters the same planner session');
|
|
2036
|
+
assert.equal(continuationEvents.some((event) => event.kind === 'terminal'), false, 'confirmed offer.check receipt does not emit a terminal batch');
|
|
2037
|
+
const checkoutEvent = continuationEvents.find((event) => event.kind === 'operation');
|
|
2038
|
+
assert.deepEqual(checkoutEvent?.action.input, { offerRef: 'server-offer', offerVersionRef: 'server-offer:v1', verifiedOfferRef: capability.verifiedOfferRef });
|
|
2039
|
+
assert.equal(checkoutServerRuntime.resumeTask('task-http-checkout-after-check')?.pendingAction?.kind, 'checkout.prepare');
|
|
2040
|
+
const checkoutHandoffTurn = {
|
|
2041
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt.continuation', taskId: 'task-http-checkout-after-check', workspace: { ...receiptWorkspace, revision: 2 },
|
|
2042
|
+
receipt: checkoutReceipt('http-checkout-after-check', 'server-offer', 'server-offer:v1', capability.verifiedOfferRef),
|
|
2043
|
+
};
|
|
2044
|
+
checkoutHandoffTurn.receipt.revision = 2;
|
|
2045
|
+
checkoutHandoffTurn.receipt.contextRef = 'ctx-checkout-after-check';
|
|
2046
|
+
const beforeCheckoutHandoffRows = checkoutServerLedger.countEvents();
|
|
2047
|
+
const checkoutHandoffResponse = await fetch(checkoutEndpoint, { method: 'POST', headers: checkoutHeaders, body: JSON.stringify(checkoutHandoffTurn) });
|
|
2048
|
+
assert.equal(checkoutHandoffResponse.status, 200);
|
|
2049
|
+
const checkoutHandoffBody = await checkoutHandoffResponse.text();
|
|
2050
|
+
const checkoutHandoffEvents = parseEvents(checkoutHandoffBody);
|
|
2051
|
+
assert.deepEqual(checkoutHandoffEvents.map((event) => event.kind), ['terminal'], 'checkout handoff receipt receives runtime-owned terminal without planner');
|
|
2052
|
+
assert.equal(checkoutHandoffEvents[0]?.kind === 'terminal' ? checkoutHandoffEvents[0].terminal.summary : '', 'checkout_handoff_prepared');
|
|
2053
|
+
assert.equal(checkoutPlannerCalls, 2, 'checkout handoff terminal does not call the planner again');
|
|
2054
|
+
assert.equal(checkoutPlannerCloses, 1, 'runtime-owned checkout terminal releases the task-scoped planner session');
|
|
2055
|
+
assert.equal(checkoutServerRuntime.resumeTask('task-http-checkout-after-check')?.phase, 'terminal');
|
|
2056
|
+
const afterCheckoutHandoffRows = checkoutServerLedger.countEvents();
|
|
2057
|
+
const replayCheckoutHandoff = await fetch(checkoutEndpoint, { method: 'POST', headers: checkoutHeaders, body: JSON.stringify(checkoutHandoffTurn) });
|
|
2058
|
+
assert.equal(replayCheckoutHandoff.status, 200);
|
|
2059
|
+
assert.equal(await replayCheckoutHandoff.text(), checkoutHandoffBody, 'checkout handoff terminal batch replays exactly once');
|
|
2060
|
+
assert.equal(checkoutPlannerCalls, 2);
|
|
2061
|
+
assert.equal(checkoutServerLedger.countEvents() > beforeCheckoutHandoffRows, true);
|
|
2062
|
+
assert.equal(checkoutServerLedger.countEvents(), afterCheckoutHandoffRows, 'checkout handoff replay appends no second terminal');
|
|
2063
|
+
await checkoutServer.close();
|
|
2064
|
+
checkoutServerLedger.close();
|
|
2065
|
+
rmSync(checkoutServerRoot, { recursive: true, force: true });
|
|
2066
|
+
}
|
|
2067
|
+
const defaultBindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-default-binding-'));
|
|
2068
|
+
const defaultBindingLedger = ensureLedger(defaultBindingRoot);
|
|
2069
|
+
const defaultBindingRuntime = new BookingCopilotTaskRuntime(defaultBindingLedger);
|
|
2070
|
+
let defaultBindingPlannerCalls = 0;
|
|
2071
|
+
await assert.rejects(startBookingCopilotServer({
|
|
2072
|
+
apiKey: 'partial-binding-key',
|
|
2073
|
+
runtime: defaultBindingRuntime, plannerFactory: () => ({ next: async () => [] }), principal: { subject: 'partial-only', scope: 'booking:read' },
|
|
2074
|
+
}), /booking_copilot_ingress_binding_pair_required/);
|
|
2075
|
+
await assert.rejects(startBookingCopilotServer({
|
|
2076
|
+
apiKey: 'partial-principal-key',
|
|
2077
|
+
runtime: defaultBindingRuntime, plannerFactory: () => ({ next: async () => [] }), ingressBinding: { bind: () => ({ taskId: 'partial', turnId: 'partial', contextRef: 'partial', surface: 'tenant', allowedActions: [...BOOKING_FULL_JOURNEY_ACTION_KINDS] }) },
|
|
2078
|
+
}), /booking_copilot_ingress_binding_pair_required/);
|
|
2079
|
+
const defaultBindingServer = await startBookingCopilotServer({
|
|
2080
|
+
apiKey: 'default-binding-key',
|
|
2081
|
+
runtime: defaultBindingRuntime,
|
|
2082
|
+
plannerFactory: () => ({ next: async () => { defaultBindingPlannerCalls++; return [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'default binding', factRefs: [] } }]; } }),
|
|
2083
|
+
});
|
|
2084
|
+
const defaultBindingEndpoint = `http://127.0.0.1:${defaultBindingServer.port}/a2a/booking-copilot/turn`;
|
|
2085
|
+
const defaultBindingHeaders = { ...headers, authorization: 'Bearer default-binding-key' };
|
|
2086
|
+
const defaultBindingBeforeIngress = defaultBindingLedger.countEvents();
|
|
2087
|
+
const rejectedDefaultIngress = await fetch(defaultBindingEndpoint, { method: 'POST', headers: defaultBindingHeaders, body: JSON.stringify(ingress) });
|
|
2088
|
+
assert.equal(rejectedDefaultIngress.status, 503, 'bound-turn-only mode rejects browser ingress before the ledger');
|
|
2089
|
+
assert.deepEqual(await rejectedDefaultIngress.json(), { error: { code: 'trusted_ingress_binding_required', mode: 'bff-bound-turn-only', acceptedTurnKinds: ['user.turn', 'action.receipt.continuation'] } });
|
|
2090
|
+
assert.equal(defaultBindingLedger.countEvents(), defaultBindingBeforeIngress, 'rejected browser ingress has no ledger side effects');
|
|
2091
|
+
const boundTurnForSurface = (taskId, surface, allowedActions) => ({
|
|
2092
|
+
...turn(taskId),
|
|
2093
|
+
workspace: { ...workspace(), surface, capabilities: { surface, allowedActions } },
|
|
2094
|
+
});
|
|
2095
|
+
const invalidBoundTurns = [
|
|
2096
|
+
boundTurnForSurface('task-bound-storefront-escalation', 'storefront', ['order.observe']),
|
|
2097
|
+
boundTurnForSurface('task-bound-payment-escalation', 'payment_link', ['checkout.prepare']),
|
|
2098
|
+
{ ...boundTurnForSurface('task-bound-capability-mismatch', 'storefront', ['search.run']), workspace: { ...workspace(), surface: 'storefront', capabilities: { surface: 'tenant', allowedActions: ['search.run'] } } },
|
|
2099
|
+
boundTurnForSurface('task-bound-duplicate-capability', 'tenant', ['search.run', 'search.run']),
|
|
2100
|
+
boundTurnForSurface('task-bound-unknown-capability', 'tenant', ['book']),
|
|
2101
|
+
];
|
|
2102
|
+
for (const invalidBoundTurn of invalidBoundTurns) {
|
|
2103
|
+
const before = defaultBindingLedger.countEvents();
|
|
2104
|
+
const response = await fetch(defaultBindingEndpoint, { method: 'POST', headers: defaultBindingHeaders, body: JSON.stringify(invalidBoundTurn) });
|
|
2105
|
+
assert.ok([400, 403].includes(response.status), `${invalidBoundTurn.taskId} is rejected at the schema/authority boundary`);
|
|
2106
|
+
assert.equal(defaultBindingLedger.countEvents(), before, `${invalidBoundTurn.taskId} has no ledger side effects`);
|
|
2107
|
+
}
|
|
2108
|
+
const invalidBoundContinuation = {
|
|
2109
|
+
schemaVersion: 'booking.surface',
|
|
2110
|
+
kind: 'action.receipt.continuation',
|
|
2111
|
+
taskId: 'task-bound-invalid-continuation',
|
|
2112
|
+
workspace: { ...workspace(1), surface: 'storefront', capabilities: { surface: 'storefront', allowedActions: ['order.observe'] } },
|
|
2113
|
+
receipt: {
|
|
2114
|
+
schemaVersion: 'booking.surface', kind: 'action.receipt', actionId: 'missing-action', contextRef: 'ctx-v2', status: 'failed', revision: 1,
|
|
2115
|
+
observation: { kind: 'gap', code: 'unhandled', factRefs: [] },
|
|
2116
|
+
resultContract: { outcome: 'empty', hardCriteriaMet: false, factRefs: [], gapCodes: ['unhandled'], blockers: [], relaxationsApplied: [] },
|
|
2117
|
+
},
|
|
2118
|
+
};
|
|
2119
|
+
const invalidContinuationBefore = defaultBindingLedger.countEvents();
|
|
2120
|
+
const invalidContinuationResponse = await fetch(defaultBindingEndpoint, { method: 'POST', headers: defaultBindingHeaders, body: JSON.stringify(invalidBoundContinuation) });
|
|
2121
|
+
assert.equal(invalidContinuationResponse.status, 403, 'receipt continuation cannot change to a surface-disallowed capability');
|
|
2122
|
+
assert.equal(defaultBindingLedger.countEvents(), invalidContinuationBefore, 'invalid bound continuation has no ledger side effects');
|
|
2123
|
+
assert.equal(defaultBindingPlannerCalls, 0, 'invalid bound turns do not call the planner');
|
|
2124
|
+
for (const [surface, allowedActions] of [
|
|
2125
|
+
['tenant', ['search.run']],
|
|
2126
|
+
['customer_portal', ['offers.query']],
|
|
2127
|
+
['storefront', ['search.run']],
|
|
2128
|
+
['payment_link', ['hotel.select']],
|
|
2129
|
+
]) {
|
|
2130
|
+
const response = await fetch(defaultBindingEndpoint, { method: 'POST', headers: defaultBindingHeaders, body: JSON.stringify(boundTurnForSurface(`task-bound-valid-${surface}`, surface, allowedActions)) });
|
|
2131
|
+
assert.equal(response.status, 200, `valid ${surface} bound turn is accepted`);
|
|
2132
|
+
assert.match(await response.text(), /event: terminal/);
|
|
2133
|
+
}
|
|
2134
|
+
assert.equal(defaultBindingPlannerCalls, 4, 'only valid surface-bound turns call the planner');
|
|
2135
|
+
await defaultBindingServer.close();
|
|
2136
|
+
assert.equal(defaultBindingLedger.countEvents() > defaultBindingBeforeIngress, true);
|
|
2137
|
+
defaultBindingLedger.close();
|
|
2138
|
+
rmSync(defaultBindingRoot, { recursive: true, force: true });
|
|
2139
|
+
// HTTP ingress retries after terminal are allowed only for the exact durable
|
|
2140
|
+
// request batch; a new request key remains fail-closed and cannot append.
|
|
2141
|
+
const terminalServerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-http-terminal-replay-'));
|
|
2142
|
+
const terminalServerLedger = ensureLedger(terminalServerRoot);
|
|
2143
|
+
const terminalServerRuntime = new BookingCopilotTaskRuntime(terminalServerLedger);
|
|
2144
|
+
const terminalServer = await startBookingCopilotServer({
|
|
2145
|
+
apiKey: 'terminal-server-key',
|
|
2146
|
+
runtime: terminalServerRuntime,
|
|
2147
|
+
principal: { subject: 'bff-terminal', scope: 'booking:read' },
|
|
2148
|
+
ingressBinding: { bind: () => ({ taskId: 'task-http-terminal', turnId: 'http-terminal-turn', contextRef: 'ctx-server', surface: 'tenant', allowedActions: [...BOOKING_FULL_JOURNEY_ACTION_KINDS] }) },
|
|
2149
|
+
ingressMode: 'bff-ingress-binding',
|
|
2150
|
+
plannerFactory: () => ({ next: async () => [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'terminal replay', factRefs: [] } }] }),
|
|
2151
|
+
});
|
|
2152
|
+
const terminalIngressEndpoint = `http://127.0.0.1:${terminalServer.port}/a2a/booking-copilot/turn`;
|
|
2153
|
+
const terminalIngress = { ...ingress, requestKey: 'terminal-request-1' };
|
|
2154
|
+
const terminalIngressHeaders = { ...headers, authorization: 'Bearer terminal-server-key' };
|
|
2155
|
+
const terminalFirst = await fetch(terminalIngressEndpoint, { method: 'POST', headers: terminalIngressHeaders, body: JSON.stringify(terminalIngress) });
|
|
2156
|
+
assert.equal(terminalFirst.status, 200);
|
|
2157
|
+
const terminalFirstBody = await terminalFirst.text();
|
|
2158
|
+
const terminalIngressCount = terminalServerLedger.countEvents();
|
|
2159
|
+
const terminalReplay = await fetch(terminalIngressEndpoint, { method: 'POST', headers: terminalIngressHeaders, body: JSON.stringify(terminalIngress) });
|
|
2160
|
+
assert.equal(terminalReplay.status, 200);
|
|
2161
|
+
assert.equal(await terminalReplay.text(), terminalFirstBody, 'terminal ingress exact request batch replays after terminal');
|
|
2162
|
+
assert.equal(terminalServerLedger.countEvents(), terminalIngressCount);
|
|
2163
|
+
const terminalNewRequest = await fetch(terminalIngressEndpoint, { method: 'POST', headers: terminalIngressHeaders, body: JSON.stringify({ ...terminalIngress, requestKey: 'terminal-request-2' }) });
|
|
2164
|
+
assert.equal(terminalNewRequest.status, 409, 'new terminal ingress request key is rejected');
|
|
2165
|
+
assert.equal(terminalServerLedger.countEvents(), terminalIngressCount);
|
|
2166
|
+
await terminalServer.close();
|
|
2167
|
+
terminalServerLedger.close();
|
|
2168
|
+
rmSync(terminalServerRoot, { recursive: true, force: true });
|
|
2169
|
+
// The BFF binding owns surface authority. Storefront/payment_link cannot be
|
|
2170
|
+
// escalated to offer, checkout, or order actions, and a browser hint mismatch
|
|
2171
|
+
// is rejected rather than changing the bound authority.
|
|
2172
|
+
for (const [surface, taskId, allowedActions, hint] of [
|
|
2173
|
+
['storefront', 'task-storefront-escalation', [...BOOKING_READ_ACTION_KINDS], 'storefront'],
|
|
2174
|
+
['payment_link', 'task-payment-escalation', [...BOOKING_READ_ACTION_KINDS], 'payment_link'],
|
|
2175
|
+
['tenant', 'task-surface-mismatch', [...BOOKING_READ_ACTION_KINDS], 'storefront'],
|
|
2176
|
+
]) {
|
|
2177
|
+
const matrixRoot = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${taskId}-`));
|
|
2178
|
+
const matrixLedger = ensureLedger(matrixRoot);
|
|
2179
|
+
const matrixRuntime = new BookingCopilotTaskRuntime(matrixLedger);
|
|
2180
|
+
const matrixServer = await startBookingCopilotServer({
|
|
2181
|
+
apiKey: `${taskId}-key`,
|
|
2182
|
+
runtime: matrixRuntime,
|
|
2183
|
+
principal: { subject: 'bff-matrix', scope: 'booking:read' },
|
|
2184
|
+
ingressBinding: { bind: () => ({ taskId, turnId: `${taskId}-turn`, contextRef: 'ctx-server', surface, allowedActions: [...allowedActions] }) },
|
|
2185
|
+
ingressMode: 'bff-ingress-binding',
|
|
2186
|
+
plannerFactory: () => ({ next: async () => [{ kind: 'terminal', terminal: { status: 'stopped', summary: 'must not plan', factRefs: [] } }] }),
|
|
2187
|
+
});
|
|
2188
|
+
const matrixResponse = await fetch(`http://127.0.0.1:${matrixServer.port}/a2a/booking-copilot/turn`, { method: 'POST', headers: { ...headers, authorization: `Bearer ${taskId}-key` }, body: JSON.stringify({ ...ingress, requestKey: `${taskId}-request`, surfaceHint: hint }) });
|
|
2189
|
+
assert.equal(matrixResponse.status, 502, `${surface} binding escalation/mismatch is rejected before planning`);
|
|
2190
|
+
assert.equal(matrixLedger.countEvents(), 0);
|
|
2191
|
+
await matrixServer.close();
|
|
2192
|
+
matrixLedger.close();
|
|
2193
|
+
rmSync(matrixRoot, { recursive: true, force: true });
|
|
2194
|
+
}
|
|
2195
|
+
// Runtime/planner exceptions are always a non-empty typed error SSE event;
|
|
2196
|
+
// neither an unsupported operation nor raw planner text becomes durable.
|
|
2197
|
+
const serverLogSecretMarker = 'ISSUE_3580_PROVIDER_SDK_SECRET_MUST_NOT_BE_LOGGED';
|
|
2198
|
+
for (const [label, plannerFactory, expectedCode] of [
|
|
2199
|
+
['runtime-unsupported-action', () => ({ next: async () => [{ kind: 'operation', action: { ...action('disallowed-http-action'), kind: 'offers.query', input: { hotelRefs: ['hotel-a'], criteria: {} } }, intent: offersLoadedIntent }] }), 'UNSUPPORTED_ACTION'],
|
|
2200
|
+
['planner-surface-unsupported', () => ({ next: async () => { throw new Error('planner_surface_action_unsupported: raw internal detail'); } }), 'PLANNER_SURFACE_ACTION_UNSUPPORTED'],
|
|
2201
|
+
['planner-provider-secret', () => ({ next: async () => { throw new Error(`provider SDK response ${serverLogSecretMarker}`); } }), 'PLANNER_FAILED'],
|
|
2202
|
+
]) {
|
|
2203
|
+
const errorRoot = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${label}-`));
|
|
2204
|
+
const errorLedger = ensureLedger(errorRoot);
|
|
2205
|
+
const errorRuntime = new BookingCopilotTaskRuntime(errorLedger);
|
|
2206
|
+
const errorTaskId = `task-${label}`;
|
|
2207
|
+
let errorPlannerCalls = 0;
|
|
2208
|
+
const errorServer = await startBookingCopilotServer({
|
|
2209
|
+
apiKey: `${label}-key`,
|
|
2210
|
+
runtime: errorRuntime,
|
|
2211
|
+
principal: { subject: `bff-${label}`, scope: 'booking:read' },
|
|
2212
|
+
ingressBinding: { bind: () => ({ taskId: errorTaskId, turnId: `${errorTaskId}-turn`, contextRef: 'ctx-v2', surface: 'tenant', allowedActions: ['search.run'] }) },
|
|
2213
|
+
ingressMode: 'bff-ingress-binding',
|
|
2214
|
+
plannerFactory: (_initial) => {
|
|
2215
|
+
const session = plannerFactory();
|
|
2216
|
+
return { next: async (_input) => { errorPlannerCalls++; return session.next(); } };
|
|
2217
|
+
},
|
|
2218
|
+
});
|
|
2219
|
+
const errorTurn = { ...turn(errorTaskId), workspace: { ...workspace(), capabilities: { surface: 'tenant', allowedActions: ['search.run'] } } };
|
|
2220
|
+
const serverErrorLogs = [];
|
|
2221
|
+
const originalServerConsoleError = console.error;
|
|
2222
|
+
const errorResponse = await (async () => {
|
|
2223
|
+
try {
|
|
2224
|
+
console.error = (...args) => { serverErrorLogs.push(args.map((value) => String(value)).join(' ')); };
|
|
2225
|
+
return await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, { method: 'POST', headers: { ...headers, authorization: `Bearer ${label}-key` }, body: JSON.stringify(errorTurn) });
|
|
2226
|
+
}
|
|
2227
|
+
finally {
|
|
2228
|
+
console.error = originalServerConsoleError;
|
|
2229
|
+
}
|
|
2230
|
+
})();
|
|
2231
|
+
assert.equal(errorResponse.status, 200, `${label} keeps the committed SSE status while returning a typed error`);
|
|
2232
|
+
const errorBody = await errorResponse.text();
|
|
2233
|
+
assert.match(errorBody, /event: error/, `${label} never returns an empty SSE body`);
|
|
2234
|
+
assert.match(errorBody, new RegExp(`"code":"${expectedCode}"`), `${label} normalizes to the closed uppercase code`);
|
|
2235
|
+
assert.doesNotMatch(errorBody, /raw internal detail/, `${label} does not expose internal error text`);
|
|
2236
|
+
assert.equal(serverErrorLogs.some((line) => line.includes(expectedCode)), true, `${label} stderr retains the closed diagnostic code`);
|
|
2237
|
+
assert.equal(serverErrorLogs.some((line) => line.includes(serverLogSecretMarker)), false, `${label} stderr omits provider-authored secret text`);
|
|
2238
|
+
assert.doesNotMatch(errorBody, /event: operation/, `${label} emits no disallowed operation`);
|
|
2239
|
+
const actionRows = errorLedger.db.prepare("SELECT count(*) AS count FROM events WHERE kind = 'booking.copilot.action.issued'").get();
|
|
2240
|
+
assert.equal(actionRows.count, 0, `${label} has zero disallowed operation side effect`);
|
|
2241
|
+
const afterErrorRows = errorLedger.countEvents();
|
|
2242
|
+
assert.equal(errorPlannerCalls, 1, `${label} calls planner once before durable typed error`);
|
|
2243
|
+
const errorReplay = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, { method: 'POST', headers: { ...headers, authorization: `Bearer ${label}-key` }, body: JSON.stringify(errorTurn) });
|
|
2244
|
+
assert.equal(errorReplay.status, 200, `${label} terminal typed error request replays as SSE, not HTTP conflict`);
|
|
2245
|
+
assert.equal(await errorReplay.text(), errorBody, `${label} typed error replay is byte-identical`);
|
|
2246
|
+
assert.equal(errorLedger.countEvents(), afterErrorRows, `${label} typed error replay appends zero ledger rows`);
|
|
2247
|
+
assert.equal(errorPlannerCalls, 1, `${label} typed error replay does not call planner again`);
|
|
2248
|
+
const errorConflict = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, { method: 'POST', headers: { ...headers, authorization: `Bearer ${label}-key` }, body: JSON.stringify({ ...errorTurn, request: { text: `${errorTurn.request.text} changed` } }) });
|
|
2249
|
+
assert.equal(errorConflict.status, 409, `${label} same turn identity with changed body remains a conflict`);
|
|
2250
|
+
await errorServer.close();
|
|
2251
|
+
errorLedger.close();
|
|
2252
|
+
rmSync(errorRoot, { recursive: true, force: true });
|
|
2253
|
+
}
|
|
2254
|
+
// Error is currently an absorbing task state. Until the public protocol has
|
|
2255
|
+
// an idempotent same-task retry command, the emitted contract must not claim
|
|
2256
|
+
// that retrying the same task can succeed.
|
|
2257
|
+
{
|
|
2258
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-terminal-error-retry-'));
|
|
2259
|
+
const ledger = ensureLedger(root);
|
|
2260
|
+
const rt = new BookingCopilotTaskRuntime(ledger, {
|
|
2261
|
+
contextRefFactory: () => 'ctx-terminal-error-retry',
|
|
2262
|
+
now: () => '2026-09-01T10:00:00.000Z',
|
|
2263
|
+
});
|
|
2264
|
+
const errorTurn = {
|
|
2265
|
+
...turn('task-terminal-error-retry'),
|
|
2266
|
+
workspace: { ...workspace(), contextRef: 'ctx-terminal-error-retry' },
|
|
2267
|
+
};
|
|
2268
|
+
const task = rt.startTask(errorTurn);
|
|
2269
|
+
const events = rt.applyDecisionBatch(task.taskId, 'terminal-error-retry-batch', [{
|
|
2270
|
+
kind: 'error',
|
|
2271
|
+
error: { code: 'PLANNER_PROVIDER_TIMEOUT', message: 'provider detail', retryable: true },
|
|
2272
|
+
}]);
|
|
2273
|
+
const errorEvent = events.find((event) => event.kind === 'error');
|
|
2274
|
+
assert.ok(errorEvent?.kind === 'error');
|
|
2275
|
+
assert.equal(errorEvent.error.retryable, false, 'terminal error never advertises an impossible same-task retry');
|
|
2276
|
+
assert.equal(rt.resumeTask(task.taskId)?.phase, 'error', 'normalized error retains absorbing task semantics');
|
|
2277
|
+
assert.deepEqual(rt.applyDecisionBatch(task.taskId, 'terminal-error-retry-batch', [{ kind: 'error', error: { code: 'PLANNER_PROVIDER_TIMEOUT', message: 'different replay input', retryable: true } }]), events, 'durable replay preserves the normalized retry contract');
|
|
2278
|
+
ledger.close();
|
|
2279
|
+
rmSync(root, { recursive: true, force: true });
|
|
2280
|
+
}
|
|
2281
|
+
// Default composition is BFF-bound-turn-only. Browser ingress remains
|
|
2282
|
+
// unavailable until the trusted BFF principal/binding seam is supplied.
|
|
2283
|
+
const boundOnlyRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-bound-only-'));
|
|
2284
|
+
const boundOnlyLedger = ensureLedger(boundOnlyRoot);
|
|
2285
|
+
const boundOnlyRuntime = new BookingCopilotTaskRuntime(boundOnlyLedger);
|
|
2286
|
+
const boundOnlyServer = await startBookingCopilotServer({ apiKey: 'bound-only-key', runtime: boundOnlyRuntime, plannerFactory: () => ({ next: async () => [] }) });
|
|
2287
|
+
const boundOnlyHealth = await fetch(`http://127.0.0.1:${boundOnlyServer.port}/healthz`, { headers: { authorization: 'Bearer bound-only-key' } });
|
|
2288
|
+
assert.equal(boundOnlyHealth.status, 200);
|
|
2289
|
+
assert.deepEqual(await boundOnlyHealth.json(), { schemaVersion: 'booking.surface', schemaSha256: BOOKING_SURFACE_SCHEMA_SHA256, status: 'ready', ingressMode: 'bff-bound-turn-only', acceptedTurnKinds: ['user.turn', 'action.receipt.continuation'], features: [...BOOKING_SURFACE_FEATURES] }, 'bound-turn-only health reports the closed turn-kind set and additive features');
|
|
2290
|
+
await boundOnlyServer.close();
|
|
2291
|
+
boundOnlyLedger.close();
|
|
2292
|
+
rmSync(boundOnlyRoot, { recursive: true, force: true });
|
|
2293
|
+
const ingressBindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-ingress-binding-'));
|
|
2294
|
+
const ingressBindingLedger = ensureLedger(ingressBindingRoot);
|
|
2295
|
+
const ingressBindingRuntime = new BookingCopilotTaskRuntime(ingressBindingLedger);
|
|
2296
|
+
const ingressBindingServer = await startBookingCopilotServer({
|
|
2297
|
+
apiKey: 'ingress-binding-key',
|
|
2298
|
+
runtime: ingressBindingRuntime,
|
|
2299
|
+
principal: { subject: 'bff-ingress-binding', scope: 'booking:read' },
|
|
2300
|
+
ingressBinding: { bind: (input) => ({ taskId: `task-${input.requestKey}`, turnId: `turn-${input.requestKey}`, contextRef: `ctx-${input.requestKey}`, surface: 'tenant', allowedActions: [...BOOKING_FULL_JOURNEY_ACTION_KINDS], observableOrders: [{ orderRef: 'order-bound-1', factRefs: ['order:state:bound-1'] }] }) },
|
|
2301
|
+
ingressMode: 'bff-ingress-binding',
|
|
2302
|
+
plannerFactory: () => ({ next: async ({ task: current }) => {
|
|
2303
|
+
const order = current.workspaceSnapshot?.observableOrders?.[0];
|
|
2304
|
+
if (!order)
|
|
2305
|
+
return [];
|
|
2306
|
+
return [{ kind: 'operation', intent: { schemaVersion: 'booking.intent.v1', target: 'order.observed' }, action: { ...action('server-order-observe', current.revision), kind: 'order.observe', contextRef: current.contextRef, factRefs: [...order.factRefs], input: { orderRef: order.orderRef } } }];
|
|
2307
|
+
} }),
|
|
2308
|
+
});
|
|
2309
|
+
const ingressBindingHealth = await fetch(`http://127.0.0.1:${ingressBindingServer.port}/healthz`, { headers: { authorization: 'Bearer ingress-binding-key' } });
|
|
2310
|
+
assert.equal(ingressBindingHealth.status, 200);
|
|
2311
|
+
assert.deepEqual(await ingressBindingHealth.json(), { schemaVersion: 'booking.surface', schemaSha256: BOOKING_SURFACE_SCHEMA_SHA256, status: 'ready', ingressMode: 'bff-ingress-binding', acceptedTurnKinds: ['user.turn', 'action.receipt.continuation', 'user.turn.ingress'], features: [...BOOKING_SURFACE_FEATURES] }, 'ingress-binding health reports the complete trusted ingress mode and additive features');
|
|
2312
|
+
const boundOrderIngress = { ...ingress, requestKey: 'ingress-bound-order-1', taskHandle: undefined };
|
|
2313
|
+
const unnegotiatedOrderResponse = await fetch(`http://127.0.0.1:${ingressBindingServer.port}/a2a/booking-copilot/turn`, { method: 'POST', headers: { ...headers, authorization: 'Bearer ingress-binding-key' }, body: JSON.stringify({ ...boundOrderIngress, requestKey: 'ingress-unnegotiated-order-1' }) });
|
|
2314
|
+
assert.equal(unnegotiatedOrderResponse.status, 200, 'an old same-line base matrix remains usable when the optional feature is absent');
|
|
2315
|
+
const unnegotiatedOrderBody = await unnegotiatedOrderResponse.text();
|
|
2316
|
+
assert.doesNotMatch(unnegotiatedOrderBody, /order\.observe|order-bound-1|order:state:bound-1/, 'missing fleet feature evidence strips both optional action and trusted projection');
|
|
2317
|
+
const boundOrderResponse = await fetch(`http://127.0.0.1:${ingressBindingServer.port}/a2a/booking-copilot/turn`, { method: 'POST', headers: { ...headers, authorization: 'Bearer ingress-binding-key', 'x-booking-surface-features': 'trusted-order-observation-v1' }, body: JSON.stringify(boundOrderIngress) });
|
|
2318
|
+
const boundOrderBody = await boundOrderResponse.text();
|
|
2319
|
+
assert.equal(boundOrderResponse.status, 200, `BFF binding can inject an observable order into a fresh ingress task: ${boundOrderBody}`);
|
|
2320
|
+
assert.match(boundOrderBody, /event: operation/);
|
|
2321
|
+
assert.match(boundOrderBody, /order-bound-1/);
|
|
2322
|
+
assert.match(boundOrderBody, /order:state:bound-1/);
|
|
2323
|
+
const forgedIngressOrder = { ...ingress, requestKey: 'ingress-forged-order-1', workspace: { ...ingress.workspace, observableOrders: [{ orderRef: 'order-browser-forged', factRefs: [] }] } };
|
|
2324
|
+
const forgedIngressOrderResponse = await fetch(`http://127.0.0.1:${ingressBindingServer.port}/a2a/booking-copilot/turn`, { method: 'POST', headers: { ...headers, authorization: 'Bearer ingress-binding-key' }, body: JSON.stringify(forgedIngressOrder) });
|
|
2325
|
+
assert.equal(forgedIngressOrderResponse.status, 400, 'browser ingress cannot smuggle observable order authority');
|
|
2326
|
+
await ingressBindingServer.close();
|
|
2327
|
+
ingressBindingLedger.close();
|
|
2328
|
+
rmSync(ingressBindingRoot, { recursive: true, force: true });
|
|
2329
|
+
runtime['ledger'].close();
|
|
2330
|
+
crossLedger.close();
|
|
2331
|
+
awaitingRestart['ledger'].close();
|
|
2332
|
+
reopened['ledger'].close();
|
|
2333
|
+
serverRuntime['ledger'].close();
|
|
2334
|
+
crashLedger.close();
|
|
2335
|
+
reverseLedger.close();
|
|
2336
|
+
terminalLedger.close();
|
|
2337
|
+
forgedSourceRuntime['ledger'].close();
|
|
2338
|
+
tamperLedger.close();
|
|
2339
|
+
offerRuntime['ledger'].close();
|
|
2340
|
+
rmSync(stateRoot, { recursive: true, force: true });
|
|
2341
|
+
rmSync(serverRoot, { recursive: true, force: true });
|
|
2342
|
+
rmSync(forgedSourceRoot, { recursive: true, force: true });
|
|
2343
|
+
rmSync(tamperRoot, { recursive: true, force: true });
|
|
2344
|
+
rmSync(crashRoot, { recursive: true, force: true });
|
|
2345
|
+
rmSync(reverseRoot, { recursive: true, force: true });
|
|
2346
|
+
rmSync(crossRoot, { recursive: true, force: true });
|
|
2347
|
+
rmSync(terminalRoot, { recursive: true, force: true });
|
|
2348
|
+
rmSync(offerRoot, { recursive: true, force: true });
|
|
2349
|
+
console.log('BOOKING COPILOT RUNTIME PROOF: task scope, receipt binding, factRef non-authority, approval authority/one-time, recovery, ingress/SSE session OK');
|