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