@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.20
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 +42 -16
- package/README.zh-CN.md +37 -15
- package/bin/gotry-bootstrap.js +309 -30
- package/bin/gotry-inner.js +74 -23
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +7 -8
- package/cordis.gotry-patch.yml +22 -8
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach.js +4 -4
- package/dist/capabilities/anything.js +62 -29
- package/dist/capabilities/channel-health.js +103 -0
- package/dist/capabilities/channel-registry.js +213 -0
- package/dist/capabilities/doctor.js +307 -0
- package/dist/capabilities/effect.js +178 -5
- package/dist/capabilities/flyai.js +13 -0
- package/dist/capabilities/hbcli.js +28 -2
- package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
- package/dist/capabilities/session/adapters/rail-12306.js +285 -0
- package/dist/capabilities/session/extension-bridge.js +14 -1
- package/dist/capabilities/session/extension-channel.js +4 -2
- package/dist/capabilities/session/extension-distribution.js +1 -1
- package/dist/capabilities/session-search.js +311 -0
- package/dist/capabilities/visa-policy.js +126 -0
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +305 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
- package/dist/scripts/anything-tests.js +70 -21
- package/dist/scripts/benchmark-environment-bridge-e2e.js +139 -37
- package/dist/scripts/benchmark-environment-bridge-tests.js +1099 -306
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
- package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
- package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
- package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
- package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
- package/dist/scripts/bootstrap-tests.js +113 -2
- package/dist/scripts/build-metrics-report.js +339 -0
- package/dist/scripts/channel-probe-tests.js +148 -0
- package/dist/scripts/channel-probe.js +252 -0
- package/dist/scripts/channel-registry-tests.js +262 -0
- package/dist/scripts/doctor-tests.js +175 -0
- package/dist/scripts/effect-tests.js +274 -1
- package/dist/scripts/extension-tests.js +129 -11
- package/dist/scripts/fact-gate-tests.js +91 -2
- package/dist/scripts/flyai-tests.js +17 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -3
- package/dist/scripts/hbcli-tests.js +38 -1
- package/dist/scripts/metrics-report-tests.js +288 -0
- package/dist/scripts/persona-surface-guard-tests.js +19 -0
- package/dist/scripts/session-tests.js +401 -1
- package/dist/scripts/smoke.js +100 -83
- package/dist/scripts/turn-handoff-collect-tests.js +216 -0
- package/dist/scripts/turn-handoff-collect.js +189 -0
- package/dist/scripts/turn-policy-tests.js +56 -0
- package/dist/scripts/typed-contract-canary.js +261 -0
- package/dist/scripts/visa-policy-tests.js +75 -0
- package/dist/scripts/wish-channel-gate-tests.js +111 -0
- package/dist/src/artifact-gate.js +91 -3
- package/dist/src/benchmark-agent-conformance.js +39 -16
- package/dist/src/benchmark-environment-bridge.js +316 -52
- package/dist/src/bookable-facts.js +62 -3
- package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
- package/dist/src/booking-surface/canonical-schema.js +8 -22
- package/dist/src/booking-surface/contracts.js +70 -19
- package/dist/src/booking-surface/dsh-planner.js +310 -177
- package/dist/src/booking-surface/dsh-plugin.js +3 -3
- package/dist/src/booking-surface/index.js +1 -5
- package/dist/src/booking-surface/profile.js +1 -1
- package/dist/src/booking-surface/runtime.js +1682 -247
- package/dist/src/booking-surface/server.js +371 -185
- package/dist/src/booking-surface/startup.js +17 -27
- package/dist/src/booking-surface/validation.js +270 -760
- package/dist/src/index.js +739 -150
- package/dist/src/turn-deadline.js +292 -0
- package/dist/src/turn-policy.js +146 -0
- package/dist/src/wish-pool.js +5 -0
- package/extension/background.js +23 -4
- package/extension/content-main.js +47 -9
- package/extension/manifest.json +27 -9
- package/package.json +7 -26
- package/schemas/booking.surface.schema.json +2593 -0
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/anything.ts +89 -38
- package/ts/capabilities/flyai.ts +21 -3
- package/ts/capabilities/hbcli.ts +53 -4
- package/ts/capabilities/session/action-cache.ts +1 -1
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
- package/ts/capabilities/session/extension-bridge.ts +32 -2
- package/ts/capabilities/session/extension-channel.ts +9 -8
- package/ts/capabilities/session/extension-distribution.ts +3 -1
- package/ts/capabilities/session/health-watch.ts +1 -1
- package/ts/capabilities/session/read-guard.ts +1 -1
- package/ts/capabilities/session/wizard.ts +1 -1
- package/ts/capabilities/session-search.ts +323 -1
- package/ts/dsh-runtime/vendor/README.md +60 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +200 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +202 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +216 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +371 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +56 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +126 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +109 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +27 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +170 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +53 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +20 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +91 -0
- package/ts/package.json +9 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +82 -2
- package/ts/src/benchmark-agent-conformance.ts +54 -15
- package/ts/src/benchmark-environment-bridge.ts +234 -46
- package/ts/src/bookable-facts.ts +109 -5
- package/ts/src/booking-saga.ts +1 -1
- package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
- package/ts/src/booking-surface/canonical-schema.js +8 -22
- package/ts/src/booking-surface/contracts.ts +278 -239
- package/ts/src/booking-surface/dsh-planner.ts +333 -194
- package/ts/src/booking-surface/dsh-plugin.js +3 -3
- package/ts/src/booking-surface/index.ts +1 -5
- package/ts/src/booking-surface/profile.ts +11 -11
- package/ts/src/booking-surface/runtime.ts +1351 -351
- package/ts/src/booking-surface/server.ts +282 -175
- package/ts/src/booking-surface/startup.ts +36 -48
- package/ts/src/booking-surface/validation.ts +194 -442
- package/ts/src/contracts.ts +1 -1
- package/ts/src/index.ts +492 -172
- package/ts/src/loop.ts +1 -1
- package/ts/src/state-ledger.ts +1 -1
- package/ts/src/turn-deadline.ts +365 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/wish-pool.ts +17 -4
- package/dist/scripts/agent-planning-budget-e2e.js +0 -227
- package/dist/scripts/agent-planning-budget-tests.js +0 -173
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
- package/dist/src/booking-surface/contracts-v2.js +0 -89
- package/dist/src/booking-surface/runtime-v2.js +0 -1771
- package/dist/src/booking-surface/server-v2.js +0 -334
- package/dist/src/booking-surface/validation-v2.js +0 -319
- package/dist/src/tool-budget.js +0 -136
- package/schemas/booking.surface.v1.schema.json +0 -927
- package/schemas/booking.surface.v2.schema.json +0 -61
- package/ts/src/booking-surface/contracts-v2.ts +0 -118
- package/ts/src/booking-surface/runtime-v2.ts +0 -1466
- package/ts/src/booking-surface/server-v2.ts +0 -247
- package/ts/src/booking-surface/validation-v2.ts +0 -205
- package/ts/src/tool-budget.ts +0 -165
|
@@ -1,3935 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { ensureLedger } from '../src/state-ledger.js';
|
|
6
|
-
import { BookingCopilotTaskRuntime } from '../src/booking-surface/runtime.js';
|
|
7
|
-
import { BookingCopilotTaskRuntimeV2, bookingV2Digest } from '../src/booking-surface/runtime-v2.js';
|
|
8
|
-
import { startBookingCopilotServer } from '../src/booking-surface/server.js';
|
|
9
|
-
import { BOOKING_READ_ACTION_KINDS_V2, BOOKING_SURFACE_SCHEMA_VERSION_V2 } from '../src/booking-surface/contracts-v2.js';
|
|
10
|
-
import { BOOKING_SURFACE_SCHEMA_V2_SHA256 } from '../src/booking-surface/contracts-v2.js';
|
|
11
|
-
import { BOOKING_SURFACE_SCHEMA_SHA256, BOOKING_SURFACE_SCHEMA_VERSION } from '../src/booking-surface/contracts.js';
|
|
12
|
-
import { validateBookingSurfaceV2 } from '../src/booking-surface/validation-v2.js';
|
|
13
|
-
const stateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-runtime-'));
|
|
14
|
-
const workspace = (revision = 0)=>({
|
|
15
|
-
schemaVersion: 'booking.surface.v2',
|
|
16
|
-
contextRef: 'ctx-v2',
|
|
17
|
-
surface: 'tenant',
|
|
18
|
-
revision,
|
|
19
|
-
locale: 'en-US',
|
|
20
|
-
currency: 'AED',
|
|
21
|
-
searchDraft: {},
|
|
22
|
-
results: {
|
|
23
|
-
status: 'idle'
|
|
24
|
-
},
|
|
25
|
-
visibleHotels: [],
|
|
26
|
-
loadedOffers: [],
|
|
27
|
-
shortlistedOfferRefs: [],
|
|
28
|
-
capabilities: {
|
|
29
|
-
surface: 'tenant',
|
|
30
|
-
allowedActions: [
|
|
31
|
-
...BOOKING_READ_ACTION_KINDS_V2
|
|
32
|
-
]
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
const loadedOffer = (offerRef, hotelRef, offerVersionRef = `${offerRef}:v1`)=>({
|
|
36
|
-
offerRef,
|
|
37
|
-
offerVersionRef,
|
|
38
|
-
hotelRef,
|
|
39
|
-
evidenceLevel: 'rate_loaded',
|
|
40
|
-
factRefs: []
|
|
41
|
-
});
|
|
42
|
-
const verifiedCapability = (offerRef, offerVersionRef = `${offerRef}:v1`, expiresAt = '2026-09-01T10:30:00.000Z')=>({
|
|
43
|
-
offerRef,
|
|
44
|
-
offerVersionRef,
|
|
45
|
-
verifiedOfferRef: `verified-${offerRef}`,
|
|
46
|
-
expiresAt
|
|
47
|
-
});
|
|
48
|
-
const availabilityObservation = (offerRef, checkedOfferVersionRef = `${offerRef}:v1`, available = false, currentOfferVersionRef)=>({
|
|
49
|
-
kind: 'offer.availability',
|
|
50
|
-
offerRef,
|
|
51
|
-
checkedOfferVersionRef,
|
|
52
|
-
currentOfferVersionRef: available ? currentOfferVersionRef ?? checkedOfferVersionRef : undefined,
|
|
53
|
-
available,
|
|
54
|
-
changedFactRefs: [],
|
|
55
|
-
gapCodes: []
|
|
56
|
-
});
|
|
57
|
-
const oldWorkspaceDigest = (value)=>{
|
|
58
|
-
const { contextRef: _contextRef, ...withoutContext } = value;
|
|
59
|
-
return bookingV2Digest(withoutContext);
|
|
60
|
-
};
|
|
61
|
-
const oldWorkspaceSemanticDigest = (value)=>{
|
|
62
|
-
const { contextRef: _contextRef, revision: _revision, ...semantic } = value;
|
|
63
|
-
return bookingV2Digest(semantic);
|
|
64
|
-
};
|
|
65
|
-
const legacyWorkspace = (value, verifiedOfferRef)=>{
|
|
66
|
-
const workspaceValue = structuredClone(value);
|
|
67
|
-
const loadedOffers = Array.isArray(workspaceValue.loadedOffers) ? workspaceValue.loadedOffers : [];
|
|
68
|
-
workspaceValue.loadedOffers = loadedOffers.map((loaded)=>{
|
|
69
|
-
const oldOffer = {
|
|
70
|
-
...loaded
|
|
71
|
-
};
|
|
72
|
-
delete oldOffer.offerVersionRef;
|
|
73
|
-
return oldOffer;
|
|
74
|
-
});
|
|
75
|
-
delete workspaceValue.verifiedOffer;
|
|
76
|
-
if (verifiedOfferRef !== undefined) workspaceValue.verifiedOfferRef = verifiedOfferRef;
|
|
77
|
-
return workspaceValue;
|
|
78
|
-
};
|
|
79
|
-
const legacyAvailability = (value, workspaceDigest)=>{
|
|
80
|
-
const availability = structuredClone(value);
|
|
81
|
-
const hotels = availability.hotels;
|
|
82
|
-
if (hotels && typeof hotels === 'object' && !Array.isArray(hotels)) {
|
|
83
|
-
for (const hotel of Object.values(hotels)){
|
|
84
|
-
if (!hotel || typeof hotel !== 'object' || Array.isArray(hotel)) continue;
|
|
85
|
-
const oldHotel = hotel;
|
|
86
|
-
delete oldHotel.tombstonedOfferVersionRefs;
|
|
87
|
-
const generation = oldHotel.currentGeneration;
|
|
88
|
-
if (workspaceDigest && generation && typeof generation === 'object' && !Array.isArray(generation)) {
|
|
89
|
-
const source = generation.source;
|
|
90
|
-
if (source && typeof source === 'object' && !Array.isArray(source) && source.kind === 'workspace_snapshot') source.workspaceDigest = workspaceDigest;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (Array.isArray(availability.attempts)) {
|
|
95
|
-
availability.attempts = availability.attempts.map((attempt)=>{
|
|
96
|
-
if (!attempt || typeof attempt !== 'object' || Array.isArray(attempt)) return attempt;
|
|
97
|
-
const oldAttempt = {
|
|
98
|
-
...attempt
|
|
99
|
-
};
|
|
100
|
-
delete oldAttempt.offerVersionRef;
|
|
101
|
-
return oldAttempt;
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
return availability;
|
|
105
|
-
};
|
|
106
|
-
const stripLegacyReceipt = (value)=>{
|
|
107
|
-
const receipt = structuredClone(value);
|
|
108
|
-
const observation = receipt.observation;
|
|
109
|
-
if (observation && typeof observation === 'object' && !Array.isArray(observation)) {
|
|
110
|
-
const oldObservation = observation;
|
|
111
|
-
delete oldObservation.offerVersionRef;
|
|
112
|
-
delete oldObservation.checkedOfferVersionRef;
|
|
113
|
-
delete oldObservation.currentOfferVersionRef;
|
|
114
|
-
}
|
|
115
|
-
return receipt;
|
|
116
|
-
};
|
|
117
|
-
const legacyTurnDigest = (taskId, turnId, workspaceValue, text)=>bookingV2Digest({
|
|
118
|
-
schemaVersion: 'booking.surface.v2',
|
|
119
|
-
kind: 'user.turn',
|
|
120
|
-
taskId,
|
|
121
|
-
turnId,
|
|
122
|
-
workspace: workspaceValue,
|
|
123
|
-
request: {
|
|
124
|
-
text
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
const turn = (taskId, revision = 0)=>({
|
|
128
|
-
schemaVersion: 'booking.surface.v2',
|
|
129
|
-
kind: 'user.turn',
|
|
130
|
-
taskId,
|
|
131
|
-
turnId: `${taskId}-turn-${revision}`,
|
|
132
|
-
workspace: workspace(revision),
|
|
133
|
-
request: {
|
|
134
|
-
text: 'find hotels in Dubai'
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
const action = (id, revision = 0, extra = {})=>({
|
|
138
|
-
schemaVersion: 'booking.surface.v2',
|
|
139
|
-
kind: 'search.run',
|
|
140
|
-
actionId: id,
|
|
141
|
-
contextRef: 'ctx-v2',
|
|
142
|
-
expectedRevision: revision,
|
|
143
|
-
reason: 'search current workspace',
|
|
144
|
-
factRefs: [],
|
|
145
|
-
input: {},
|
|
146
|
-
...extra
|
|
147
|
-
});
|
|
148
|
-
let id = 0;
|
|
149
|
-
const runtime = new BookingCopilotTaskRuntimeV2(ensureLedger(stateRoot), {
|
|
150
|
-
idFactory: (prefix)=>`${prefix}-${++id}`,
|
|
151
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
152
|
-
});
|
|
153
|
-
const task = runtime.startTask(turn('task-v2'));
|
|
154
|
-
assert.equal(task.taskId, 'task-v2');
|
|
155
|
-
assert.throws(()=>runtime.startTask({
|
|
156
|
-
...turn('task-no-id'),
|
|
157
|
-
taskId: undefined
|
|
158
|
-
}), /invalid_planner_turn/);
|
|
159
|
-
const directIngressRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-missing-task-'));
|
|
160
|
-
const directIngressLedger = ensureLedger(directIngressRoot);
|
|
161
|
-
const directIngressRuntime = new BookingCopilotTaskRuntimeV2(directIngressLedger);
|
|
162
|
-
const directIngressBefore = directIngressLedger.countEvents();
|
|
163
|
-
assert.throws(()=>directIngressRuntime.startTask({
|
|
164
|
-
schemaVersion: 'booking.surface.v2',
|
|
165
|
-
kind: 'user.turn.ingress',
|
|
166
|
-
requestKey: 'ingress-no-task',
|
|
167
|
-
surfaceHint: 'tenant',
|
|
168
|
-
workspace: {
|
|
169
|
-
schemaVersion: 'booking.surface.v2',
|
|
170
|
-
revision: 0,
|
|
171
|
-
locale: 'en-US',
|
|
172
|
-
currency: 'AED',
|
|
173
|
-
searchDraft: {},
|
|
174
|
-
results: {
|
|
175
|
-
status: 'idle'
|
|
176
|
-
},
|
|
177
|
-
visibleHotels: [],
|
|
178
|
-
loadedOffers: [],
|
|
179
|
-
shortlistedOfferRefs: []
|
|
180
|
-
},
|
|
181
|
-
request: {
|
|
182
|
-
text: 'find hotels in Dubai'
|
|
183
|
-
}
|
|
184
|
-
}), /ingress_binding_required/);
|
|
185
|
-
assert.equal(directIngressLedger.countEvents(), directIngressBefore, 'browser ingress has no direct runtime ledger side effects');
|
|
186
|
-
const selectedIngressRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-selected-ingress-'));
|
|
187
|
-
const selectedIngressLedger = ensureLedger(selectedIngressRoot);
|
|
188
|
-
const selectedIngressRuntime = new BookingCopilotTaskRuntimeV2(selectedIngressLedger, {
|
|
189
|
-
contextRefFactory: ()=>'ctx-selected-ingress'
|
|
190
|
-
});
|
|
191
|
-
const selectedIngress = {
|
|
192
|
-
schemaVersion: 'booking.surface.v2',
|
|
193
|
-
kind: 'user.turn.ingress',
|
|
194
|
-
requestKey: 'selected-ingress-request',
|
|
195
|
-
taskHandle: 'opaque-task-handle',
|
|
196
|
-
surfaceHint: 'tenant',
|
|
197
|
-
workspace: {
|
|
198
|
-
schemaVersion: 'booking.surface.v2',
|
|
199
|
-
revision: 0,
|
|
200
|
-
locale: 'en-US',
|
|
201
|
-
currency: 'AED',
|
|
202
|
-
searchDraft: {},
|
|
203
|
-
results: {
|
|
204
|
-
status: 'idle'
|
|
205
|
-
},
|
|
206
|
-
visibleHotels: [
|
|
207
|
-
{
|
|
208
|
-
hotelRef: 'hotel-selected',
|
|
209
|
-
name: 'Selected Hotel',
|
|
210
|
-
factRefs: []
|
|
211
|
-
}
|
|
212
|
-
],
|
|
213
|
-
loadedOffers: [
|
|
214
|
-
loadedOffer('offer-selected', 'hotel-selected')
|
|
215
|
-
],
|
|
216
|
-
focusedHotelRef: 'hotel-selected',
|
|
217
|
-
shortlistedOfferRefs: [],
|
|
218
|
-
selectedOfferRef: 'offer-selected'
|
|
219
|
-
},
|
|
220
|
-
request: {
|
|
221
|
-
text: 'is this selected room still bookable?'
|
|
222
|
-
}
|
|
223
|
-
};
|
|
224
|
-
const selectedIngressTask = selectedIngressRuntime.startTask(turn('task-selected-ingress'));
|
|
225
|
-
const selectedPlannerTurn = selectedIngressRuntime.bindIngressTurn(selectedIngress, selectedIngressTask, 'turn-selected-ingress');
|
|
226
|
-
assert.equal(selectedPlannerTurn.workspace.focusedHotelRef, 'hotel-selected');
|
|
227
|
-
assert.equal(selectedPlannerTurn.workspace.selectedOfferRef, 'offer-selected');
|
|
228
|
-
assert.equal(selectedPlannerTurn.workspace.verifiedOffer, undefined, 'initial ingress cannot assert verified availability authority');
|
|
229
|
-
selectedIngressLedger.close();
|
|
230
|
-
rmSync(selectedIngressRoot, {
|
|
231
|
-
recursive: true,
|
|
232
|
-
force: true
|
|
233
|
-
});
|
|
234
|
-
const duplicateOfferWorkspace = {
|
|
235
|
-
...workspace(0),
|
|
236
|
-
visibleHotels: [
|
|
237
|
-
{
|
|
238
|
-
hotelRef: 'hotel-dup-a',
|
|
239
|
-
name: 'Hotel Dup A',
|
|
240
|
-
factRefs: []
|
|
241
|
-
},
|
|
242
|
-
{
|
|
243
|
-
hotelRef: 'hotel-dup-b',
|
|
244
|
-
name: 'Hotel Dup B',
|
|
245
|
-
factRefs: []
|
|
246
|
-
}
|
|
247
|
-
],
|
|
248
|
-
loadedOffers: [
|
|
249
|
-
loadedOffer('duplicate-offer-ref', 'hotel-dup-a'),
|
|
250
|
-
loadedOffer('duplicate-offer-ref', 'hotel-dup-b')
|
|
251
|
-
]
|
|
252
|
-
};
|
|
253
|
-
const duplicateRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-duplicate-offer-'));
|
|
254
|
-
const duplicateLedger = ensureLedger(duplicateRoot);
|
|
255
|
-
const duplicateRuntime = new BookingCopilotTaskRuntimeV2(duplicateLedger, {
|
|
256
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
257
|
-
});
|
|
258
|
-
assert.equal(validateBookingSurfaceV2({
|
|
259
|
-
...turn('validator-duplicate-logical'),
|
|
260
|
-
workspace: duplicateOfferWorkspace
|
|
261
|
-
}).ok, false, 'published validator rejects duplicate logical offer refs');
|
|
262
|
-
const duplicateVersionWorkspace = {
|
|
263
|
-
...workspace(0),
|
|
264
|
-
visibleHotels: [
|
|
265
|
-
{
|
|
266
|
-
hotelRef: 'hotel-dup-a',
|
|
267
|
-
name: 'Hotel Dup A',
|
|
268
|
-
factRefs: []
|
|
269
|
-
},
|
|
270
|
-
{
|
|
271
|
-
hotelRef: 'hotel-dup-b',
|
|
272
|
-
name: 'Hotel Dup B',
|
|
273
|
-
factRefs: []
|
|
274
|
-
}
|
|
275
|
-
],
|
|
276
|
-
loadedOffers: [
|
|
277
|
-
loadedOffer('offer-dup-a', 'hotel-dup-a', 'shared-offer:v1'),
|
|
278
|
-
loadedOffer('offer-dup-b', 'hotel-dup-b', 'shared-offer:v1')
|
|
279
|
-
]
|
|
280
|
-
};
|
|
281
|
-
assert.equal(validateBookingSurfaceV2({
|
|
282
|
-
...turn('validator-duplicate-version'),
|
|
283
|
-
workspace: duplicateVersionWorkspace
|
|
284
|
-
}).ok, false, 'published validator rejects duplicate offer version refs');
|
|
285
|
-
const verifiedVersionMismatchWorkspace = {
|
|
286
|
-
...workspace(0),
|
|
287
|
-
visibleHotels: [
|
|
288
|
-
{
|
|
289
|
-
hotelRef: 'hotel-verified',
|
|
290
|
-
name: 'Verified Hotel',
|
|
291
|
-
factRefs: []
|
|
292
|
-
}
|
|
293
|
-
],
|
|
294
|
-
loadedOffers: [
|
|
295
|
-
loadedOffer('offer-verified', 'hotel-verified', 'offer-verified:v1')
|
|
296
|
-
],
|
|
297
|
-
verifiedOffer: verifiedCapability('offer-verified', 'offer-verified:v2')
|
|
298
|
-
};
|
|
299
|
-
assert.equal(validateBookingSurfaceV2({
|
|
300
|
-
...turn('validator-verified-version-mismatch'),
|
|
301
|
-
workspace: verifiedVersionMismatchWorkspace
|
|
302
|
-
}).ok, false, 'standalone workspace validation rejects a verified capability for an unloaded version');
|
|
303
|
-
assert.equal(validateBookingSurfaceV2({
|
|
304
|
-
...turn('validator-verified-logical-mismatch'),
|
|
305
|
-
workspace: {
|
|
306
|
-
...verifiedVersionMismatchWorkspace,
|
|
307
|
-
verifiedOffer: verifiedCapability('other-offer', 'other-offer:v1')
|
|
308
|
-
}
|
|
309
|
-
}).ok, false, 'standalone workspace validation rejects a verified capability for another logical offer');
|
|
310
|
-
assert.throws(()=>duplicateRuntime.startTask({
|
|
311
|
-
...turn('task-duplicate-offer'),
|
|
312
|
-
workspace: duplicateOfferWorkspace
|
|
313
|
-
}), /invalid_planner_turn|availability_duplicate_offer_ref/);
|
|
314
|
-
assert.equal(duplicateLedger.countEvents(), 0, 'duplicate loaded OfferRef is rejected at admission before ledger writes');
|
|
315
|
-
const duplicateContinuationRuntime = new BookingCopilotTaskRuntimeV2(duplicateLedger, {
|
|
316
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
317
|
-
});
|
|
318
|
-
const duplicateContinuationTask = duplicateContinuationRuntime.startTask({
|
|
319
|
-
...turn('task-duplicate-continuation'),
|
|
320
|
-
workspace: {
|
|
321
|
-
...workspace(0),
|
|
322
|
-
visibleHotels: [
|
|
323
|
-
{
|
|
324
|
-
hotelRef: 'hotel-dup-a',
|
|
325
|
-
name: 'Hotel Dup A',
|
|
326
|
-
factRefs: []
|
|
327
|
-
}
|
|
328
|
-
],
|
|
329
|
-
loadedOffers: [
|
|
330
|
-
loadedOffer('unique-offer-ref', 'hotel-dup-a')
|
|
331
|
-
]
|
|
332
|
-
}
|
|
333
|
-
});
|
|
334
|
-
duplicateContinuationRuntime.issueOperation(duplicateContinuationTask.taskId, {
|
|
335
|
-
...action('duplicate-continuation-check'),
|
|
336
|
-
kind: 'offer.check',
|
|
337
|
-
input: {
|
|
338
|
-
offerRef: 'unique-offer-ref',
|
|
339
|
-
offerVersionRef: 'unique-offer-ref:v1'
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
assert.throws(()=>duplicateContinuationRuntime.continueWithReceipt({
|
|
343
|
-
schemaVersion: 'booking.surface.v2',
|
|
344
|
-
kind: 'action.receipt.continuation',
|
|
345
|
-
taskId: duplicateContinuationTask.taskId,
|
|
346
|
-
workspace: {
|
|
347
|
-
...duplicateOfferWorkspace,
|
|
348
|
-
revision: 1
|
|
349
|
-
},
|
|
350
|
-
receipt: {
|
|
351
|
-
schemaVersion: 'booking.surface.v2',
|
|
352
|
-
kind: 'action.receipt',
|
|
353
|
-
actionId: 'duplicate-continuation-check',
|
|
354
|
-
contextRef: 'ctx-v2',
|
|
355
|
-
status: 'unavailable',
|
|
356
|
-
revision: 1,
|
|
357
|
-
observation: availabilityObservation('unique-offer-ref'),
|
|
358
|
-
resultContract: {
|
|
359
|
-
outcome: 'empty',
|
|
360
|
-
hardCriteriaMet: false,
|
|
361
|
-
factRefs: [],
|
|
362
|
-
gapCodes: [],
|
|
363
|
-
blockers: [],
|
|
364
|
-
relaxationsApplied: []
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}), /invalid_receipt_continuation|availability_duplicate_offer_ref/);
|
|
368
|
-
duplicateContinuationRuntime.appendTurn('task-duplicate-continuation', 'ctx-v2', 'tampered', 'tampered', 'tampered', 2, 'duplicate-replay-turn', duplicateOfferWorkspace);
|
|
369
|
-
assert.throws(()=>duplicateContinuationRuntime.resumeTask('task-duplicate-continuation'), /ledger_corrupt:task-duplicate-continuation:turn_workspace/, 'duplicate loaded OfferRef is fail-closed during ledger replay');
|
|
370
|
-
duplicateLedger.close();
|
|
371
|
-
rmSync(duplicateRoot, {
|
|
372
|
-
recursive: true,
|
|
373
|
-
force: true
|
|
374
|
-
});
|
|
375
|
-
const selectRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-select-receipt-binding-'));
|
|
376
|
-
const selectLedger = ensureLedger(selectRoot);
|
|
377
|
-
const selectRuntime = new BookingCopilotTaskRuntimeV2(selectLedger, {
|
|
378
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
379
|
-
});
|
|
380
|
-
const selectWorkspace = {
|
|
381
|
-
...workspace(0),
|
|
382
|
-
visibleHotels: [
|
|
383
|
-
{
|
|
384
|
-
hotelRef: 'hotel-a',
|
|
385
|
-
name: 'Hotel A',
|
|
386
|
-
factRefs: []
|
|
387
|
-
},
|
|
388
|
-
{
|
|
389
|
-
hotelRef: 'hotel-b',
|
|
390
|
-
name: 'Hotel B',
|
|
391
|
-
factRefs: []
|
|
392
|
-
}
|
|
393
|
-
],
|
|
394
|
-
loadedOffers: [
|
|
395
|
-
loadedOffer('offer-a', 'hotel-a'),
|
|
396
|
-
loadedOffer('offer-b', 'hotel-b')
|
|
397
|
-
],
|
|
398
|
-
shortlistedOfferRefs: [
|
|
399
|
-
'offer-a',
|
|
400
|
-
'offer-b'
|
|
401
|
-
]
|
|
402
|
-
};
|
|
403
|
-
const selectTask = selectRuntime.startTask({
|
|
404
|
-
...turn('task-select-receipt-binding'),
|
|
405
|
-
workspace: selectWorkspace
|
|
406
|
-
});
|
|
407
|
-
selectRuntime.issueOperation(selectTask.taskId, {
|
|
408
|
-
...action('select-a'),
|
|
409
|
-
kind: 'offer.select',
|
|
410
|
-
input: {
|
|
411
|
-
offerRef: 'offer-a',
|
|
412
|
-
offerVersionRef: 'offer-a:v1'
|
|
413
|
-
}
|
|
414
|
-
});
|
|
415
|
-
assert.throws(()=>selectRuntime.continueWithReceipt({
|
|
416
|
-
schemaVersion: 'booking.surface.v2',
|
|
417
|
-
kind: 'action.receipt.continuation',
|
|
418
|
-
taskId: selectTask.taskId,
|
|
419
|
-
workspace: {
|
|
420
|
-
...selectWorkspace,
|
|
421
|
-
revision: 1,
|
|
422
|
-
selectedOfferRef: 'offer-b'
|
|
423
|
-
},
|
|
424
|
-
receipt: {
|
|
425
|
-
schemaVersion: 'booking.surface.v2',
|
|
426
|
-
kind: 'action.receipt',
|
|
427
|
-
actionId: 'select-a',
|
|
428
|
-
contextRef: 'ctx-v2',
|
|
429
|
-
status: 'applied',
|
|
430
|
-
revision: 1,
|
|
431
|
-
observation: {
|
|
432
|
-
kind: 'offer.selection',
|
|
433
|
-
offerRef: 'offer-a',
|
|
434
|
-
offerVersionRef: 'offer-a:v1'
|
|
435
|
-
},
|
|
436
|
-
resultContract: {
|
|
437
|
-
outcome: 'complete',
|
|
438
|
-
hardCriteriaMet: true,
|
|
439
|
-
factRefs: [],
|
|
440
|
-
gapCodes: [],
|
|
441
|
-
blockers: [],
|
|
442
|
-
relaxationsApplied: []
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}), /workspace_mismatch|receipt_transition/, 'offer.select receipt A cannot select offer B');
|
|
446
|
-
selectLedger.close();
|
|
447
|
-
rmSync(selectRoot, {
|
|
448
|
-
recursive: true,
|
|
449
|
-
force: true
|
|
450
|
-
});
|
|
451
|
-
const checkoutWorkspace = (revision, capability)=>({
|
|
452
|
-
...workspace(revision),
|
|
453
|
-
visibleHotels: [
|
|
454
|
-
{
|
|
455
|
-
hotelRef: 'hotel-a',
|
|
456
|
-
name: 'Hotel A',
|
|
457
|
-
factRefs: []
|
|
458
|
-
},
|
|
459
|
-
{
|
|
460
|
-
hotelRef: 'hotel-b',
|
|
461
|
-
name: 'Hotel B',
|
|
462
|
-
factRefs: []
|
|
463
|
-
}
|
|
464
|
-
],
|
|
465
|
-
loadedOffers: [
|
|
466
|
-
loadedOffer('offer-a', 'hotel-a'),
|
|
467
|
-
loadedOffer('offer-b', 'hotel-b')
|
|
468
|
-
],
|
|
469
|
-
selectedOfferRef: 'offer-a',
|
|
470
|
-
verifiedOffer: capability
|
|
471
|
-
});
|
|
472
|
-
const checkoutReceipt = (actionId, offerRef, offerVersionRef, verifiedOfferRef)=>({
|
|
473
|
-
schemaVersion: 'booking.surface.v2',
|
|
474
|
-
kind: 'action.receipt',
|
|
475
|
-
actionId,
|
|
476
|
-
contextRef: 'ctx-v2',
|
|
477
|
-
status: 'applied',
|
|
478
|
-
revision: 1,
|
|
479
|
-
observation: {
|
|
480
|
-
kind: 'checkout.handoff',
|
|
481
|
-
offerRef,
|
|
482
|
-
offerVersionRef,
|
|
483
|
-
verifiedOfferRef,
|
|
484
|
-
handoffRef: `${offerRef}-checkout`
|
|
485
|
-
},
|
|
486
|
-
resultContract: {
|
|
487
|
-
outcome: 'complete',
|
|
488
|
-
hardCriteriaMet: true,
|
|
489
|
-
factRefs: [],
|
|
490
|
-
gapCodes: [],
|
|
491
|
-
blockers: [],
|
|
492
|
-
relaxationsApplied: []
|
|
493
|
-
}
|
|
494
|
-
});
|
|
495
|
-
{
|
|
496
|
-
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-'));
|
|
497
|
-
const ledger = ensureLedger(root);
|
|
498
|
-
const rt = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
499
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
500
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
501
|
-
});
|
|
502
|
-
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
503
|
-
const t = rt.startTask({
|
|
504
|
-
...turn('task-checkout-capability-exact'),
|
|
505
|
-
workspace: checkoutWorkspace(0, capability)
|
|
506
|
-
});
|
|
507
|
-
const checkout = {
|
|
508
|
-
...action('checkout-exact'),
|
|
509
|
-
kind: 'checkout.prepare',
|
|
510
|
-
input: {
|
|
511
|
-
offerRef: 'offer-a',
|
|
512
|
-
offerVersionRef: 'offer-a:v1',
|
|
513
|
-
verifiedOfferRef: capability.verifiedOfferRef
|
|
514
|
-
}
|
|
515
|
-
};
|
|
516
|
-
rt.issueOperation(t.taskId, checkout);
|
|
517
|
-
rt.continueWithReceipt({
|
|
518
|
-
schemaVersion: 'booking.surface.v2',
|
|
519
|
-
kind: 'action.receipt.continuation',
|
|
520
|
-
taskId: t.taskId,
|
|
521
|
-
workspace: checkoutWorkspace(1, capability),
|
|
522
|
-
receipt: checkoutReceipt('checkout-exact', 'offer-a', 'offer-a:v1', capability.verifiedOfferRef)
|
|
523
|
-
});
|
|
524
|
-
assert.equal(rt.resumeTask(t.taskId)?.lastReceipt?.observation.kind, 'checkout.handoff', 'exact unexpired capability reaches checkout handoff');
|
|
525
|
-
ledger.close();
|
|
526
|
-
rmSync(root, {
|
|
527
|
-
recursive: true,
|
|
528
|
-
force: true
|
|
529
|
-
});
|
|
530
|
-
}{
|
|
531
|
-
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-cross-offer-'));
|
|
532
|
-
const ledger = ensureLedger(root);
|
|
533
|
-
const rt = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
534
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
535
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
536
|
-
});
|
|
537
|
-
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
538
|
-
const t = rt.startTask({
|
|
539
|
-
...turn('task-checkout-capability-cross-offer'),
|
|
540
|
-
workspace: checkoutWorkspace(0, capability)
|
|
541
|
-
});
|
|
542
|
-
assert.throws(()=>rt.issueOperation(t.taskId, {
|
|
543
|
-
...action('checkout-cross-offer'),
|
|
544
|
-
kind: 'checkout.prepare',
|
|
545
|
-
input: {
|
|
546
|
-
offerRef: 'offer-b',
|
|
547
|
-
offerVersionRef: 'offer-b:v1',
|
|
548
|
-
verifiedOfferRef: capability.verifiedOfferRef
|
|
549
|
-
}
|
|
550
|
-
}), /verified_offer|offer_capability|offer_version/, 'offer A capability cannot authorize offer B');
|
|
551
|
-
ledger.close();
|
|
552
|
-
rmSync(root, {
|
|
553
|
-
recursive: true,
|
|
554
|
-
force: true
|
|
555
|
-
});
|
|
556
|
-
}{
|
|
557
|
-
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-checkout-capability-expired-'));
|
|
558
|
-
const ledger = ensureLedger(root);
|
|
559
|
-
const rt = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
560
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
561
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
562
|
-
});
|
|
563
|
-
const expired = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T09:59:59.000Z');
|
|
564
|
-
const t = rt.startTask({
|
|
565
|
-
...turn('task-checkout-capability-expired'),
|
|
566
|
-
workspace: checkoutWorkspace(0, expired)
|
|
567
|
-
});
|
|
568
|
-
assert.throws(()=>rt.issueOperation(t.taskId, {
|
|
569
|
-
...action('checkout-expired'),
|
|
570
|
-
kind: 'checkout.prepare',
|
|
571
|
-
input: {
|
|
572
|
-
offerRef: 'offer-a',
|
|
573
|
-
offerVersionRef: 'offer-a:v1',
|
|
574
|
-
verifiedOfferRef: expired.verifiedOfferRef
|
|
575
|
-
}
|
|
576
|
-
}), /verified_offer_expired|offer_capability_expired|capability_expired|offer_version_not_loaded/, 'expired verified capability is rejected');
|
|
577
|
-
ledger.close();
|
|
578
|
-
rmSync(root, {
|
|
579
|
-
recursive: true,
|
|
580
|
-
force: true
|
|
581
|
-
});
|
|
582
|
-
}{
|
|
583
|
-
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-expired-receipt-write-'));
|
|
584
|
-
const ledger = ensureLedger(root);
|
|
585
|
-
const rt = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
586
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
587
|
-
now: ()=>'2026-09-01T11:00:00.000Z'
|
|
588
|
-
});
|
|
589
|
-
const expired = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
590
|
-
const before = checkoutWorkspace(0, expired);
|
|
591
|
-
const after = checkoutWorkspace(1, expired);
|
|
592
|
-
const t = rt.startTask({
|
|
593
|
-
...turn('task-expired-receipt-write'),
|
|
594
|
-
workspace: before
|
|
595
|
-
});
|
|
596
|
-
rt.issueOperation(t.taskId, {
|
|
597
|
-
...action('expired-receipt-check'),
|
|
598
|
-
kind: 'offer.check',
|
|
599
|
-
input: {
|
|
600
|
-
offerRef: 'offer-a',
|
|
601
|
-
offerVersionRef: 'offer-a:v1'
|
|
602
|
-
}
|
|
603
|
-
});
|
|
604
|
-
const receipt = {
|
|
605
|
-
schemaVersion: 'booking.surface.v2',
|
|
606
|
-
kind: 'action.receipt',
|
|
607
|
-
actionId: 'expired-receipt-check',
|
|
608
|
-
contextRef: 'ctx-v2',
|
|
609
|
-
status: 'applied',
|
|
610
|
-
revision: 1,
|
|
611
|
-
observation: {
|
|
612
|
-
kind: 'offer.availability',
|
|
613
|
-
offerRef: 'offer-a',
|
|
614
|
-
checkedOfferVersionRef: 'offer-a:v1',
|
|
615
|
-
currentOfferVersionRef: 'offer-a:v1',
|
|
616
|
-
verifiedOfferRef: expired.verifiedOfferRef,
|
|
617
|
-
available: true,
|
|
618
|
-
changedFactRefs: [],
|
|
619
|
-
gapCodes: []
|
|
620
|
-
},
|
|
621
|
-
resultContract: {
|
|
622
|
-
outcome: 'complete',
|
|
623
|
-
hardCriteriaMet: true,
|
|
624
|
-
factRefs: [],
|
|
625
|
-
gapCodes: [],
|
|
626
|
-
blockers: [],
|
|
627
|
-
relaxationsApplied: []
|
|
628
|
-
}
|
|
629
|
-
};
|
|
630
|
-
assert.throws(()=>rt.continueWithReceipt({
|
|
631
|
-
schemaVersion: 'booking.surface.v2',
|
|
632
|
-
kind: 'action.receipt.continuation',
|
|
633
|
-
taskId: t.taskId,
|
|
634
|
-
workspace: after,
|
|
635
|
-
receipt
|
|
636
|
-
}), /receipt_verified_offer_expired/, 'expired capability is rejected at receipt write time');
|
|
637
|
-
ledger.close();
|
|
638
|
-
rmSync(root, {
|
|
639
|
-
recursive: true,
|
|
640
|
-
force: true
|
|
641
|
-
});
|
|
642
|
-
}{
|
|
643
|
-
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-expired-receipt-replay-'));
|
|
644
|
-
const ledger = ensureLedger(root);
|
|
645
|
-
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
646
|
-
const before = checkoutWorkspace(0, capability);
|
|
647
|
-
const after = checkoutWorkspace(1, capability);
|
|
648
|
-
const writer = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
649
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
650
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
651
|
-
});
|
|
652
|
-
const t = writer.startTask({
|
|
653
|
-
...turn('task-expired-receipt-replay'),
|
|
654
|
-
workspace: before
|
|
655
|
-
});
|
|
656
|
-
writer.issueOperation(t.taskId, {
|
|
657
|
-
...action('historical-check'),
|
|
658
|
-
kind: 'offer.check',
|
|
659
|
-
input: {
|
|
660
|
-
offerRef: 'offer-a',
|
|
661
|
-
offerVersionRef: 'offer-a:v1'
|
|
662
|
-
}
|
|
663
|
-
});
|
|
664
|
-
const historicalReceipt = {
|
|
665
|
-
schemaVersion: 'booking.surface.v2',
|
|
666
|
-
kind: 'action.receipt',
|
|
667
|
-
actionId: 'historical-check',
|
|
668
|
-
contextRef: 'ctx-v2',
|
|
669
|
-
status: 'applied',
|
|
670
|
-
revision: 1,
|
|
671
|
-
observation: {
|
|
672
|
-
kind: 'offer.availability',
|
|
673
|
-
offerRef: 'offer-a',
|
|
674
|
-
checkedOfferVersionRef: 'offer-a:v1',
|
|
675
|
-
currentOfferVersionRef: 'offer-a:v1',
|
|
676
|
-
verifiedOfferRef: capability.verifiedOfferRef,
|
|
677
|
-
available: true,
|
|
678
|
-
changedFactRefs: [],
|
|
679
|
-
gapCodes: []
|
|
680
|
-
},
|
|
681
|
-
resultContract: {
|
|
682
|
-
outcome: 'complete',
|
|
683
|
-
hardCriteriaMet: true,
|
|
684
|
-
factRefs: [],
|
|
685
|
-
gapCodes: [],
|
|
686
|
-
blockers: [],
|
|
687
|
-
relaxationsApplied: []
|
|
688
|
-
}
|
|
689
|
-
};
|
|
690
|
-
writer.continueWithReceipt({
|
|
691
|
-
schemaVersion: 'booking.surface.v2',
|
|
692
|
-
kind: 'action.receipt.continuation',
|
|
693
|
-
taskId: t.taskId,
|
|
694
|
-
workspace: after,
|
|
695
|
-
receipt: historicalReceipt
|
|
696
|
-
});
|
|
697
|
-
const reader = new BookingCopilotTaskRuntimeV2(ensureLedger(root), {
|
|
698
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
699
|
-
now: ()=>'2026-09-01T11:00:00.000Z'
|
|
700
|
-
});
|
|
701
|
-
const replayed = reader.resumeTask(t.taskId);
|
|
702
|
-
assert.equal(replayed?.lastReceipt?.status, 'applied', 'historical accepted availability receipt remains replayable after capability expiry');
|
|
703
|
-
assert.equal(replayed?.lastReceipt?.observation.kind === 'offer.availability' ? replayed.lastReceipt.observation.verifiedOfferRef : undefined, capability.verifiedOfferRef);
|
|
704
|
-
reader['ledger'].close();
|
|
705
|
-
ledger.close();
|
|
706
|
-
rmSync(root, {
|
|
707
|
-
recursive: true,
|
|
708
|
-
force: true
|
|
709
|
-
});
|
|
710
|
-
}{
|
|
711
|
-
const root = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-partial-verified-offer-'));
|
|
712
|
-
const ledger = ensureLedger(root);
|
|
713
|
-
const capability = verifiedCapability('offer-a', 'offer-a:v1', '2026-09-01T10:30:00.000Z');
|
|
714
|
-
const before = checkoutWorkspace(0, capability);
|
|
715
|
-
const after = checkoutWorkspace(1, capability);
|
|
716
|
-
const rt = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
717
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
718
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
719
|
-
});
|
|
720
|
-
const t = rt.startTask({
|
|
721
|
-
...turn('task-partial-verified-offer'),
|
|
722
|
-
workspace: before
|
|
723
|
-
});
|
|
724
|
-
rt.issueOperation(t.taskId, {
|
|
725
|
-
...action('partial-verified-check'),
|
|
726
|
-
kind: 'offer.check',
|
|
727
|
-
input: {
|
|
728
|
-
offerRef: 'offer-a',
|
|
729
|
-
offerVersionRef: 'offer-a:v1'
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
|
-
const partialReceipt = {
|
|
733
|
-
schemaVersion: 'booking.surface.v2',
|
|
734
|
-
kind: 'action.receipt',
|
|
735
|
-
actionId: 'partial-verified-check',
|
|
736
|
-
contextRef: 'ctx-v2',
|
|
737
|
-
status: 'applied',
|
|
738
|
-
revision: 1,
|
|
739
|
-
observation: {
|
|
740
|
-
kind: 'offer.availability',
|
|
741
|
-
offerRef: 'offer-a',
|
|
742
|
-
checkedOfferVersionRef: 'offer-a:v1',
|
|
743
|
-
currentOfferVersionRef: 'offer-a:v1',
|
|
744
|
-
verifiedOfferRef: capability.verifiedOfferRef,
|
|
745
|
-
available: true,
|
|
746
|
-
changedFactRefs: [],
|
|
747
|
-
gapCodes: [
|
|
748
|
-
'check_avail_unverified'
|
|
749
|
-
]
|
|
750
|
-
},
|
|
751
|
-
resultContract: {
|
|
752
|
-
outcome: 'partial',
|
|
753
|
-
hardCriteriaMet: false,
|
|
754
|
-
factRefs: [],
|
|
755
|
-
gapCodes: [
|
|
756
|
-
'check_avail_unverified'
|
|
757
|
-
],
|
|
758
|
-
blockers: [],
|
|
759
|
-
relaxationsApplied: []
|
|
760
|
-
}
|
|
761
|
-
};
|
|
762
|
-
assert.throws(()=>rt.continueWithReceipt({
|
|
763
|
-
schemaVersion: 'booking.surface.v2',
|
|
764
|
-
kind: 'action.receipt.continuation',
|
|
765
|
-
taskId: t.taskId,
|
|
766
|
-
workspace: after,
|
|
767
|
-
receipt: partialReceipt
|
|
768
|
-
}), /workspace_mismatch|receipt_verified|availability_receipt|invalid_receipt/, 'partial applied availability cannot publish a checkout-authorizing verified capability');
|
|
769
|
-
assert.throws(()=>rt.issueOperation(t.taskId, {
|
|
770
|
-
...action('partial-verified-checkout'),
|
|
771
|
-
kind: 'checkout.prepare',
|
|
772
|
-
input: {
|
|
773
|
-
offerRef: 'offer-a',
|
|
774
|
-
offerVersionRef: 'offer-a:v1',
|
|
775
|
-
verifiedOfferRef: capability.verifiedOfferRef
|
|
776
|
-
}
|
|
777
|
-
}), /receipt_required|verified_offer|offer_version/, 'partial availability cannot authorize a later checkout');
|
|
778
|
-
ledger.close();
|
|
779
|
-
rmSync(root, {
|
|
780
|
-
recursive: true,
|
|
781
|
-
force: true
|
|
782
|
-
});
|
|
783
|
-
}const operation = runtime.issueOperation(task.taskId, action('action-v2'));
|
|
784
|
-
assert.equal(operation.action.actionId, 'action-v2');
|
|
785
|
-
assert.equal(operation.action.kind, 'search.run');
|
|
786
|
-
assert.equal(runtime.resumeTask(task.taskId)?.pendingAction?.actionId, 'action-v2');
|
|
787
|
-
assert.throws(()=>runtime.issueOperation(task.taskId, action('wrong-context', 0, {
|
|
788
|
-
contextRef: 'ctx-other'
|
|
789
|
-
})), /receipt_required/);
|
|
790
|
-
assert.throws(()=>runtime.continueWithReceipt({
|
|
791
|
-
schemaVersion: 'booking.surface.v2',
|
|
792
|
-
kind: 'action.receipt.continuation',
|
|
793
|
-
taskId: task.taskId,
|
|
794
|
-
workspace: workspace(1),
|
|
795
|
-
receipt: {
|
|
796
|
-
schemaVersion: 'booking.surface.v2',
|
|
797
|
-
kind: 'action.receipt',
|
|
798
|
-
actionId: 'wrong',
|
|
799
|
-
contextRef: 'ctx-v2',
|
|
800
|
-
status: 'applied',
|
|
801
|
-
revision: 1,
|
|
802
|
-
observation: {
|
|
803
|
-
kind: 'search.state',
|
|
804
|
-
resultCount: 1
|
|
805
|
-
},
|
|
806
|
-
resultContract: {
|
|
807
|
-
outcome: 'complete',
|
|
808
|
-
hardCriteriaMet: true,
|
|
809
|
-
factRefs: [],
|
|
810
|
-
gapCodes: [],
|
|
811
|
-
blockers: [],
|
|
812
|
-
relaxationsApplied: []
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}), /receipt_action_mismatch/);
|
|
816
|
-
const receipt = {
|
|
817
|
-
schemaVersion: 'booking.surface.v2',
|
|
818
|
-
kind: 'action.receipt',
|
|
819
|
-
actionId: 'action-v2',
|
|
820
|
-
contextRef: 'ctx-v2',
|
|
821
|
-
status: 'needs_input',
|
|
822
|
-
revision: 1,
|
|
823
|
-
observation: {
|
|
824
|
-
kind: 'gap',
|
|
825
|
-
code: 'criterion_must_not_met',
|
|
826
|
-
factRefs: [
|
|
827
|
-
'fact-v2'
|
|
828
|
-
]
|
|
829
|
-
},
|
|
830
|
-
resultContract: {
|
|
831
|
-
outcome: 'partial',
|
|
832
|
-
hardCriteriaMet: false,
|
|
833
|
-
factRefs: [
|
|
834
|
-
'fact-v2'
|
|
835
|
-
],
|
|
836
|
-
gapCodes: [
|
|
837
|
-
'criterion_must_not_met'
|
|
838
|
-
],
|
|
839
|
-
blockers: [
|
|
840
|
-
{
|
|
841
|
-
blockerId: 'blocker-v2',
|
|
842
|
-
sourceActionId: 'action-v2',
|
|
843
|
-
sourceReceiptDigest: '',
|
|
844
|
-
scope: 'search',
|
|
845
|
-
code: 'criterion_must_not_met',
|
|
846
|
-
criterionPath: 'searchDraft.destination',
|
|
847
|
-
strength: 'must',
|
|
848
|
-
valueDigest: 'b'.repeat(64),
|
|
849
|
-
valueLabel: 'Dubai',
|
|
850
|
-
evidence: {
|
|
851
|
-
factRefs: [
|
|
852
|
-
'fact-v2'
|
|
853
|
-
],
|
|
854
|
-
gapCodes: [
|
|
855
|
-
'criterion_must_not_met'
|
|
856
|
-
]
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
],
|
|
860
|
-
relaxationsApplied: []
|
|
861
|
-
}
|
|
862
|
-
};
|
|
863
|
-
const receiptWithDigest = runtime.withReceiptDigest(receipt);
|
|
864
|
-
const afterReceipt = runtime.continueWithReceipt({
|
|
865
|
-
schemaVersion: 'booking.surface.v2',
|
|
866
|
-
kind: 'action.receipt.continuation',
|
|
867
|
-
taskId: task.taskId,
|
|
868
|
-
workspace: workspace(1),
|
|
869
|
-
receipt: receiptWithDigest
|
|
870
|
-
});
|
|
871
|
-
assert.equal(afterReceipt.awaitingApproval?.blocker.blockerId, 'blocker-v2');
|
|
872
|
-
const offerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-target-'));
|
|
873
|
-
const offerRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(offerRoot), {
|
|
874
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
875
|
-
});
|
|
876
|
-
const offerTurn = turn('task-offer-target');
|
|
877
|
-
const offerTask = offerRuntime.startTask({
|
|
878
|
-
...offerTurn,
|
|
879
|
-
workspace: {
|
|
880
|
-
...offerTurn.workspace,
|
|
881
|
-
visibleHotels: [
|
|
882
|
-
{
|
|
883
|
-
hotelRef: 'hotel-a',
|
|
884
|
-
name: 'Hotel A',
|
|
885
|
-
factRefs: []
|
|
886
|
-
}
|
|
887
|
-
],
|
|
888
|
-
loadedOffers: [
|
|
889
|
-
loadedOffer('offer-a', 'hotel-a')
|
|
890
|
-
]
|
|
891
|
-
}
|
|
892
|
-
});
|
|
893
|
-
offerRuntime.issueOperation(offerTask.taskId, {
|
|
894
|
-
...action('offer-action'),
|
|
895
|
-
kind: 'offer.check',
|
|
896
|
-
input: {
|
|
897
|
-
offerRef: 'offer-a',
|
|
898
|
-
offerVersionRef: 'offer-a:v1'
|
|
899
|
-
}
|
|
900
|
-
});
|
|
901
|
-
const offerReceipt = offerRuntime.withReceiptDigest({
|
|
902
|
-
...receipt,
|
|
903
|
-
actionId: 'offer-action',
|
|
904
|
-
observation: availabilityObservation('offer-b', 'offer-b:v1', true),
|
|
905
|
-
resultContract: {
|
|
906
|
-
...receipt.resultContract,
|
|
907
|
-
blockers: [],
|
|
908
|
-
gapCodes: []
|
|
909
|
-
}
|
|
910
|
-
});
|
|
911
|
-
const offerWorkspace1 = {
|
|
912
|
-
...offerTurn.workspace,
|
|
913
|
-
revision: 1,
|
|
914
|
-
visibleHotels: [
|
|
915
|
-
{
|
|
916
|
-
hotelRef: 'hotel-a',
|
|
917
|
-
name: 'Hotel A',
|
|
918
|
-
factRefs: []
|
|
919
|
-
}
|
|
920
|
-
],
|
|
921
|
-
loadedOffers: [
|
|
922
|
-
loadedOffer('offer-a', 'hotel-a')
|
|
923
|
-
]
|
|
924
|
-
};
|
|
925
|
-
assert.throws(()=>offerRuntime.continueWithReceipt({
|
|
926
|
-
schemaVersion: 'booking.surface.v2',
|
|
927
|
-
kind: 'action.receipt.continuation',
|
|
928
|
-
taskId: offerTask.taskId,
|
|
929
|
-
workspace: offerWorkspace1,
|
|
930
|
-
receipt: offerReceipt
|
|
931
|
-
}), /receipt_target_mismatch/);
|
|
932
|
-
const offerCheckWorkspace = (revision, selectedOfferRef, shortlistedOfferRefs = [
|
|
933
|
-
'offer-cleanup',
|
|
934
|
-
'offer-peer'
|
|
935
|
-
], verifiedOffer)=>({
|
|
936
|
-
...workspace(revision),
|
|
937
|
-
visibleHotels: [
|
|
938
|
-
{
|
|
939
|
-
hotelRef: 'hotel-cleanup',
|
|
940
|
-
name: 'Hotel Cleanup',
|
|
941
|
-
factRefs: []
|
|
942
|
-
}
|
|
943
|
-
],
|
|
944
|
-
loadedOffers: [
|
|
945
|
-
loadedOffer('offer-cleanup', 'hotel-cleanup'),
|
|
946
|
-
loadedOffer('offer-peer', 'hotel-cleanup')
|
|
947
|
-
],
|
|
948
|
-
selectedOfferRef,
|
|
949
|
-
shortlistedOfferRefs,
|
|
950
|
-
...verifiedOffer ? {
|
|
951
|
-
verifiedOffer
|
|
952
|
-
} : {}
|
|
953
|
-
});
|
|
954
|
-
const checkReceipt = (actionId, status, available = status === 'changed')=>({
|
|
955
|
-
schemaVersion: 'booking.surface.v2',
|
|
956
|
-
kind: 'action.receipt',
|
|
957
|
-
actionId,
|
|
958
|
-
contextRef: 'ctx-v2',
|
|
959
|
-
status,
|
|
960
|
-
revision: 1,
|
|
961
|
-
observation: {
|
|
962
|
-
kind: 'offer.availability',
|
|
963
|
-
offerRef: 'offer-cleanup',
|
|
964
|
-
checkedOfferVersionRef: 'offer-cleanup:v1',
|
|
965
|
-
currentOfferVersionRef: available ? status === 'changed' ? 'offer-cleanup:v2' : 'offer-cleanup:v1' : undefined,
|
|
966
|
-
available,
|
|
967
|
-
verifiedOfferRef: available && status !== 'changed' ? 'verified-offer-cleanup' : undefined,
|
|
968
|
-
changedFactRefs: status === 'changed' ? [
|
|
969
|
-
'price'
|
|
970
|
-
] : [],
|
|
971
|
-
gapCodes: []
|
|
972
|
-
},
|
|
973
|
-
resultContract: {
|
|
974
|
-
outcome: status === 'changed' ? 'partial' : available ? 'complete' : status === 'unavailable' || status === 'no_match' ? 'empty' : 'partial',
|
|
975
|
-
hardCriteriaMet: available && status !== 'changed',
|
|
976
|
-
factRefs: [],
|
|
977
|
-
gapCodes: [],
|
|
978
|
-
blockers: [],
|
|
979
|
-
relaxationsApplied: []
|
|
980
|
-
}
|
|
981
|
-
});
|
|
982
|
-
const assertOfferCheckContinuation = (name, before, after, receipt, pattern)=>{
|
|
983
|
-
const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${name}-`));
|
|
984
|
-
const rt = new BookingCopilotTaskRuntimeV2(ensureLedger(root), {
|
|
985
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
986
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
987
|
-
});
|
|
988
|
-
const t = rt.startTask({
|
|
989
|
-
...turn(`task-${name}`),
|
|
990
|
-
workspace: before
|
|
991
|
-
});
|
|
992
|
-
rt.issueOperation(t.taskId, {
|
|
993
|
-
...action(`${name}-action`),
|
|
994
|
-
kind: 'offer.check',
|
|
995
|
-
input: {
|
|
996
|
-
offerRef: 'offer-cleanup',
|
|
997
|
-
offerVersionRef: 'offer-cleanup:v1'
|
|
998
|
-
}
|
|
999
|
-
});
|
|
1000
|
-
if (pattern) assert.throws(()=>rt.continueWithReceipt({
|
|
1001
|
-
schemaVersion: 'booking.surface.v2',
|
|
1002
|
-
kind: 'action.receipt.continuation',
|
|
1003
|
-
taskId: t.taskId,
|
|
1004
|
-
workspace: after,
|
|
1005
|
-
receipt: {
|
|
1006
|
-
...receipt,
|
|
1007
|
-
actionId: `${name}-action`
|
|
1008
|
-
}
|
|
1009
|
-
}), pattern);
|
|
1010
|
-
else rt.continueWithReceipt({
|
|
1011
|
-
schemaVersion: 'booking.surface.v2',
|
|
1012
|
-
kind: 'action.receipt.continuation',
|
|
1013
|
-
taskId: t.taskId,
|
|
1014
|
-
workspace: after,
|
|
1015
|
-
receipt: {
|
|
1016
|
-
...receipt,
|
|
1017
|
-
actionId: `${name}-action`
|
|
1018
|
-
}
|
|
1019
|
-
});
|
|
1020
|
-
rt['ledger'].close();
|
|
1021
|
-
rmSync(root, {
|
|
1022
|
-
recursive: true,
|
|
1023
|
-
force: true
|
|
1024
|
-
});
|
|
1025
|
-
};
|
|
1026
|
-
assertOfferCheckContinuation('offer-success-first-check', offerCheckWorkspace(0, undefined), offerCheckWorkspace(1, 'offer-cleanup', [
|
|
1027
|
-
'offer-cleanup',
|
|
1028
|
-
'offer-peer'
|
|
1029
|
-
], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-first-check-action', 'applied', true));
|
|
1030
|
-
assertOfferCheckContinuation('offer-success-peer-to-checked', offerCheckWorkspace(0, 'offer-peer'), offerCheckWorkspace(1, 'offer-cleanup', [
|
|
1031
|
-
'offer-cleanup',
|
|
1032
|
-
'offer-peer'
|
|
1033
|
-
], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-peer-to-checked-action', 'applied', true));
|
|
1034
|
-
assertOfferCheckContinuation('offer-success-cleared-forbidden', offerCheckWorkspace(0, 'offer-peer'), offerCheckWorkspace(1, undefined, [
|
|
1035
|
-
'offer-peer'
|
|
1036
|
-
], verifiedCapability('offer-cleanup')), checkReceipt('offer-success-cleared-forbidden-action', 'applied', true), /workspace_mismatch/);
|
|
1037
|
-
const cleanupRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-'));
|
|
1038
|
-
const cleanupRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(cleanupRoot), {
|
|
1039
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
1040
|
-
});
|
|
1041
|
-
const cleanupWorkspace0 = {
|
|
1042
|
-
...workspace(0),
|
|
1043
|
-
visibleHotels: [
|
|
1044
|
-
{
|
|
1045
|
-
hotelRef: 'hotel-cleanup',
|
|
1046
|
-
name: 'Hotel Cleanup',
|
|
1047
|
-
factRefs: []
|
|
1048
|
-
}
|
|
1049
|
-
],
|
|
1050
|
-
loadedOffers: [
|
|
1051
|
-
loadedOffer('offer-cleanup', 'hotel-cleanup'),
|
|
1052
|
-
loadedOffer('offer-peer', 'hotel-cleanup')
|
|
1053
|
-
],
|
|
1054
|
-
selectedOfferRef: 'offer-cleanup',
|
|
1055
|
-
shortlistedOfferRefs: [
|
|
1056
|
-
'offer-cleanup',
|
|
1057
|
-
'offer-peer'
|
|
1058
|
-
],
|
|
1059
|
-
verifiedOffer: verifiedCapability('offer-cleanup', 'offer-cleanup:v1', '2026-09-01T10:30:00.000Z')
|
|
1060
|
-
};
|
|
1061
|
-
const cleanupTask = cleanupRuntime.startTask({
|
|
1062
|
-
...turn('task-offer-cleanup'),
|
|
1063
|
-
workspace: cleanupWorkspace0
|
|
1064
|
-
});
|
|
1065
|
-
const cleanupAction = {
|
|
1066
|
-
...action('offer-cleanup-action'),
|
|
1067
|
-
kind: 'offer.check',
|
|
1068
|
-
input: {
|
|
1069
|
-
offerRef: 'offer-cleanup',
|
|
1070
|
-
offerVersionRef: 'offer-cleanup:v1'
|
|
1071
|
-
}
|
|
1072
|
-
};
|
|
1073
|
-
cleanupRuntime.issueOperation(cleanupTask.taskId, cleanupAction);
|
|
1074
|
-
const cleanupReceipt = {
|
|
1075
|
-
schemaVersion: 'booking.surface.v2',
|
|
1076
|
-
kind: 'action.receipt',
|
|
1077
|
-
actionId: 'offer-cleanup-action',
|
|
1078
|
-
contextRef: 'ctx-v2',
|
|
1079
|
-
status: 'unavailable',
|
|
1080
|
-
revision: 1,
|
|
1081
|
-
observation: {
|
|
1082
|
-
kind: 'offer.availability',
|
|
1083
|
-
offerRef: 'offer-cleanup',
|
|
1084
|
-
checkedOfferVersionRef: 'offer-cleanup:v1',
|
|
1085
|
-
available: false,
|
|
1086
|
-
changedFactRefs: [],
|
|
1087
|
-
gapCodes: []
|
|
1088
|
-
},
|
|
1089
|
-
resultContract: {
|
|
1090
|
-
outcome: 'empty',
|
|
1091
|
-
hardCriteriaMet: false,
|
|
1092
|
-
factRefs: [],
|
|
1093
|
-
gapCodes: [],
|
|
1094
|
-
blockers: [],
|
|
1095
|
-
relaxationsApplied: []
|
|
1096
|
-
}
|
|
1097
|
-
};
|
|
1098
|
-
const cleanupWorkspace1 = {
|
|
1099
|
-
...cleanupWorkspace0,
|
|
1100
|
-
revision: 1,
|
|
1101
|
-
selectedOfferRef: undefined,
|
|
1102
|
-
shortlistedOfferRefs: [
|
|
1103
|
-
'offer-peer'
|
|
1104
|
-
],
|
|
1105
|
-
verifiedOffer: undefined
|
|
1106
|
-
};
|
|
1107
|
-
cleanupRuntime.continueWithReceipt({
|
|
1108
|
-
schemaVersion: 'booking.surface.v2',
|
|
1109
|
-
kind: 'action.receipt.continuation',
|
|
1110
|
-
taskId: cleanupTask.taskId,
|
|
1111
|
-
workspace: cleanupWorkspace1,
|
|
1112
|
-
receipt: cleanupReceipt
|
|
1113
|
-
});
|
|
1114
|
-
assert.equal(cleanupRuntime.resumeTask(cleanupTask.taskId)?.lastReceipt?.status, 'unavailable', 'offer.check can clear only the checked selection/shortlist and stale verification while preserving loaded offer evidence');
|
|
1115
|
-
assertOfferCheckContinuation('offer-negative-kept-checked-forbidden', cleanupWorkspace0, {
|
|
1116
|
-
...cleanupWorkspace1,
|
|
1117
|
-
selectedOfferRef: 'offer-cleanup',
|
|
1118
|
-
shortlistedOfferRefs: [
|
|
1119
|
-
'offer-cleanup',
|
|
1120
|
-
'offer-peer'
|
|
1121
|
-
]
|
|
1122
|
-
}, cleanupReceipt, /workspace_mismatch/);
|
|
1123
|
-
const cleanupChangedWorkspace1 = {
|
|
1124
|
-
...cleanupWorkspace0,
|
|
1125
|
-
revision: 1,
|
|
1126
|
-
loadedOffers: [
|
|
1127
|
-
{
|
|
1128
|
-
...loadedOffer('offer-cleanup', 'hotel-cleanup', 'offer-cleanup:v2'),
|
|
1129
|
-
factRefs: [
|
|
1130
|
-
'price'
|
|
1131
|
-
]
|
|
1132
|
-
},
|
|
1133
|
-
loadedOffer('offer-peer', 'hotel-cleanup')
|
|
1134
|
-
],
|
|
1135
|
-
verifiedOffer: undefined
|
|
1136
|
-
};
|
|
1137
|
-
const changedRuntimeRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-changed-runtime-'));
|
|
1138
|
-
const changedRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(changedRuntimeRoot), {
|
|
1139
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
1140
|
-
});
|
|
1141
|
-
const changedTask = changedRuntime.startTask({
|
|
1142
|
-
...turn('task-offer-changed-runtime'),
|
|
1143
|
-
workspace: cleanupWorkspace0
|
|
1144
|
-
});
|
|
1145
|
-
changedRuntime.issueOperation(changedTask.taskId, {
|
|
1146
|
-
...action('offer-changed-seed-query'),
|
|
1147
|
-
kind: 'offers.query',
|
|
1148
|
-
input: {
|
|
1149
|
-
hotelRefs: [
|
|
1150
|
-
'hotel-cleanup'
|
|
1151
|
-
],
|
|
1152
|
-
criteria: {}
|
|
1153
|
-
}
|
|
1154
|
-
});
|
|
1155
|
-
const changedSeedWorkspace = {
|
|
1156
|
-
...cleanupWorkspace0,
|
|
1157
|
-
revision: 1
|
|
1158
|
-
};
|
|
1159
|
-
changedRuntime.continueWithReceipt({
|
|
1160
|
-
schemaVersion: 'booking.surface.v2',
|
|
1161
|
-
kind: 'action.receipt.continuation',
|
|
1162
|
-
taskId: changedTask.taskId,
|
|
1163
|
-
workspace: changedSeedWorkspace,
|
|
1164
|
-
receipt: {
|
|
1165
|
-
schemaVersion: 'booking.surface.v2',
|
|
1166
|
-
kind: 'action.receipt',
|
|
1167
|
-
actionId: 'offer-changed-seed-query',
|
|
1168
|
-
contextRef: 'ctx-v2',
|
|
1169
|
-
status: 'applied',
|
|
1170
|
-
revision: 1,
|
|
1171
|
-
observation: {
|
|
1172
|
-
kind: 'offers.state',
|
|
1173
|
-
hotelRefs: [
|
|
1174
|
-
'hotel-cleanup'
|
|
1175
|
-
],
|
|
1176
|
-
offerRefs: [
|
|
1177
|
-
'offer-cleanup',
|
|
1178
|
-
'offer-peer'
|
|
1179
|
-
],
|
|
1180
|
-
loadedHotelCount: 1
|
|
1181
|
-
},
|
|
1182
|
-
resultContract: {
|
|
1183
|
-
outcome: 'complete',
|
|
1184
|
-
hardCriteriaMet: true,
|
|
1185
|
-
factRefs: [],
|
|
1186
|
-
gapCodes: [],
|
|
1187
|
-
blockers: [],
|
|
1188
|
-
relaxationsApplied: []
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
});
|
|
1192
|
-
changedRuntime.issueOperation(changedTask.taskId, {
|
|
1193
|
-
...action('offer-changed-runtime-check', 1),
|
|
1194
|
-
kind: 'offer.check',
|
|
1195
|
-
input: {
|
|
1196
|
-
offerRef: 'offer-cleanup',
|
|
1197
|
-
offerVersionRef: 'offer-cleanup:v1'
|
|
1198
|
-
}
|
|
1199
|
-
});
|
|
1200
|
-
changedRuntime.continueWithReceipt({
|
|
1201
|
-
schemaVersion: 'booking.surface.v2',
|
|
1202
|
-
kind: 'action.receipt.continuation',
|
|
1203
|
-
taskId: changedTask.taskId,
|
|
1204
|
-
workspace: {
|
|
1205
|
-
...cleanupChangedWorkspace1,
|
|
1206
|
-
revision: 2
|
|
1207
|
-
},
|
|
1208
|
-
receipt: {
|
|
1209
|
-
...checkReceipt('offer-changed-runtime-check', 'changed'),
|
|
1210
|
-
revision: 2
|
|
1211
|
-
}
|
|
1212
|
-
});
|
|
1213
|
-
assert.equal(changedRuntime.resumeTask(changedTask.taskId)?.lastReceipt?.status, 'changed', 'runtime persists changed availability against a replacement offer version');
|
|
1214
|
-
changedRuntime['ledger'].close();
|
|
1215
|
-
rmSync(changedRuntimeRoot, {
|
|
1216
|
-
recursive: true,
|
|
1217
|
-
force: true
|
|
1218
|
-
});
|
|
1219
|
-
const directChangedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-direct-offer-changed-'));
|
|
1220
|
-
const directChangedRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(directChangedRoot), {
|
|
1221
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
1222
|
-
});
|
|
1223
|
-
const directChangedTask = directChangedRuntime.startTask({
|
|
1224
|
-
...turn('task-direct-offer-changed'),
|
|
1225
|
-
workspace: cleanupWorkspace0
|
|
1226
|
-
});
|
|
1227
|
-
directChangedRuntime.issueOperation(directChangedTask.taskId, {
|
|
1228
|
-
...action('direct-offer-check-v1'),
|
|
1229
|
-
kind: 'offer.check',
|
|
1230
|
-
input: {
|
|
1231
|
-
offerRef: 'offer-cleanup',
|
|
1232
|
-
offerVersionRef: 'offer-cleanup:v1'
|
|
1233
|
-
}
|
|
1234
|
-
});
|
|
1235
|
-
const directChangedWorkspace = {
|
|
1236
|
-
...cleanupChangedWorkspace1,
|
|
1237
|
-
revision: 1
|
|
1238
|
-
};
|
|
1239
|
-
directChangedRuntime.continueWithReceipt({
|
|
1240
|
-
schemaVersion: 'booking.surface.v2',
|
|
1241
|
-
kind: 'action.receipt.continuation',
|
|
1242
|
-
taskId: directChangedTask.taskId,
|
|
1243
|
-
workspace: directChangedWorkspace,
|
|
1244
|
-
receipt: checkReceipt('direct-offer-check-v1', 'changed')
|
|
1245
|
-
});
|
|
1246
|
-
assert.equal(directChangedRuntime.resumeTask(directChangedTask.taskId)?.lastReceipt?.status, 'changed', 'direct first CheckAvail changed receipt is durable');
|
|
1247
|
-
const directChangedRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(directChangedRoot), {
|
|
1248
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
1249
|
-
});
|
|
1250
|
-
const resumedDirect = directChangedRestart.resumeTask(directChangedTask.taskId);
|
|
1251
|
-
assert.equal(resumedDirect?.lastReceipt?.observation.kind, 'offer.availability');
|
|
1252
|
-
assert.equal(resumedDirect?.lastReceipt?.observation.kind === 'offer.availability' ? resumedDirect.lastReceipt.observation.currentOfferVersionRef : undefined, 'offer-cleanup:v2', 'restart retains replacement offer version');
|
|
1253
|
-
const recheckV2 = {
|
|
1254
|
-
...action('direct-offer-check-v2', 1),
|
|
1255
|
-
kind: 'offer.check',
|
|
1256
|
-
input: {
|
|
1257
|
-
offerRef: 'offer-cleanup',
|
|
1258
|
-
offerVersionRef: 'offer-cleanup:v2'
|
|
1259
|
-
}
|
|
1260
|
-
};
|
|
1261
|
-
directChangedRestart.issueOperation(directChangedTask.taskId, recheckV2);
|
|
1262
|
-
directChangedRuntime['ledger'].close();
|
|
1263
|
-
directChangedRestart['ledger'].close();
|
|
1264
|
-
rmSync(directChangedRoot, {
|
|
1265
|
-
recursive: true,
|
|
1266
|
-
force: true
|
|
1267
|
-
});
|
|
1268
|
-
assertOfferCheckContinuation('offer-no-match-cleanup', cleanupWorkspace0, cleanupWorkspace1, {
|
|
1269
|
-
...cleanupReceipt,
|
|
1270
|
-
status: 'no_match'
|
|
1271
|
-
});
|
|
1272
|
-
assertOfferCheckContinuation('offer-failed-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
|
|
1273
|
-
...cleanupReceipt,
|
|
1274
|
-
status: 'failed',
|
|
1275
|
-
resultContract: {
|
|
1276
|
-
...cleanupReceipt.resultContract,
|
|
1277
|
-
outcome: 'partial'
|
|
1278
|
-
}
|
|
1279
|
-
}, /workspace_mismatch/);
|
|
1280
|
-
assertOfferCheckContinuation('offer-stale-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
|
|
1281
|
-
...cleanupReceipt,
|
|
1282
|
-
status: 'stale',
|
|
1283
|
-
resultContract: {
|
|
1284
|
-
...cleanupReceipt.resultContract,
|
|
1285
|
-
outcome: 'partial'
|
|
1286
|
-
}
|
|
1287
|
-
}, /workspace_mismatch/);
|
|
1288
|
-
assertOfferCheckContinuation('offer-unsupported-drift-forbidden', cleanupWorkspace0, cleanupWorkspace1, {
|
|
1289
|
-
...cleanupReceipt,
|
|
1290
|
-
status: 'unsupported',
|
|
1291
|
-
resultContract: {
|
|
1292
|
-
...cleanupReceipt.resultContract,
|
|
1293
|
-
outcome: 'partial'
|
|
1294
|
-
}
|
|
1295
|
-
}, /workspace_mismatch/);
|
|
1296
|
-
const replacedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-replaced-'));
|
|
1297
|
-
const replacedRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(replacedRoot), {
|
|
1298
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
1299
|
-
});
|
|
1300
|
-
const replacedTask = replacedRuntime.startTask({
|
|
1301
|
-
...turn('task-offer-cleanup-replaced'),
|
|
1302
|
-
workspace: cleanupWorkspace0
|
|
1303
|
-
});
|
|
1304
|
-
replacedRuntime.issueOperation(replacedTask.taskId, {
|
|
1305
|
-
...cleanupAction,
|
|
1306
|
-
actionId: 'offer-cleanup-replaced'
|
|
1307
|
-
});
|
|
1308
|
-
assert.throws(()=>replacedRuntime.continueWithReceipt({
|
|
1309
|
-
schemaVersion: 'booking.surface.v2',
|
|
1310
|
-
kind: 'action.receipt.continuation',
|
|
1311
|
-
taskId: replacedTask.taskId,
|
|
1312
|
-
workspace: {
|
|
1313
|
-
...cleanupWorkspace1,
|
|
1314
|
-
selectedOfferRef: 'offer-peer'
|
|
1315
|
-
},
|
|
1316
|
-
receipt: {
|
|
1317
|
-
...cleanupReceipt,
|
|
1318
|
-
actionId: 'offer-cleanup-replaced'
|
|
1319
|
-
}
|
|
1320
|
-
}), /workspace_mismatch/, 'offer.check cannot replace selection with a different offer');
|
|
1321
|
-
const loadedDriftRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-offer-cleanup-loaded-drift-'));
|
|
1322
|
-
const loadedDriftRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(loadedDriftRoot), {
|
|
1323
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
1324
|
-
});
|
|
1325
|
-
const loadedDriftTask = loadedDriftRuntime.startTask({
|
|
1326
|
-
...turn('task-offer-cleanup-loaded-drift'),
|
|
1327
|
-
workspace: cleanupWorkspace0
|
|
1328
|
-
});
|
|
1329
|
-
loadedDriftRuntime.issueOperation(loadedDriftTask.taskId, {
|
|
1330
|
-
...cleanupAction,
|
|
1331
|
-
actionId: 'offer-cleanup-loaded-drift'
|
|
1332
|
-
});
|
|
1333
|
-
assert.throws(()=>loadedDriftRuntime.continueWithReceipt({
|
|
1334
|
-
schemaVersion: 'booking.surface.v2',
|
|
1335
|
-
kind: 'action.receipt.continuation',
|
|
1336
|
-
taskId: loadedDriftTask.taskId,
|
|
1337
|
-
workspace: {
|
|
1338
|
-
...cleanupWorkspace1,
|
|
1339
|
-
loadedOffers: [
|
|
1340
|
-
loadedOffer('offer-peer', 'hotel-cleanup')
|
|
1341
|
-
]
|
|
1342
|
-
},
|
|
1343
|
-
receipt: {
|
|
1344
|
-
...cleanupReceipt,
|
|
1345
|
-
actionId: 'offer-cleanup-loaded-drift'
|
|
1346
|
-
}
|
|
1347
|
-
}), /workspace_mismatch/, 'offer.check cannot mutate loadedOffers evidence');
|
|
1348
|
-
cleanupRuntime['ledger'].close();
|
|
1349
|
-
replacedRuntime['ledger'].close();
|
|
1350
|
-
loadedDriftRuntime['ledger'].close();
|
|
1351
|
-
rmSync(cleanupRoot, {
|
|
1352
|
-
recursive: true,
|
|
1353
|
-
force: true
|
|
1354
|
-
});
|
|
1355
|
-
rmSync(replacedRoot, {
|
|
1356
|
-
recursive: true,
|
|
1357
|
-
force: true
|
|
1358
|
-
});
|
|
1359
|
-
rmSync(loadedDriftRoot, {
|
|
1360
|
-
recursive: true,
|
|
1361
|
-
force: true
|
|
1362
|
-
});
|
|
1363
|
-
const approvalQuestion = runtime.approvalQuestion(task.taskId);
|
|
1364
|
-
assert.equal(approvalQuestion.kind, 'question');
|
|
1365
|
-
if (approvalQuestion.kind === 'question') {
|
|
1366
|
-
assert.equal(approvalQuestion.question.blocker.blockerId, 'blocker-v2');
|
|
1367
|
-
assert.equal(approvalQuestion.question.approvalOptions[0]?.approval.sourceActionId, 'action-v2');
|
|
1368
|
-
}
|
|
1369
|
-
const awaitingRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(stateRoot), {
|
|
1370
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
1371
|
-
});
|
|
1372
|
-
assert.equal(awaitingRestart.resumeTask(task.taskId)?.awaitingApproval?.blocker.blockerId, 'blocker-v2');
|
|
1373
|
-
const approval = approvalQuestion.kind === 'question' ? approvalQuestion.question.approvalOptions[0].approval : (()=>{
|
|
1374
|
-
throw new Error('missing approval option');
|
|
1375
|
-
})();
|
|
1376
|
-
assert.throws(()=>runtime.startTask({
|
|
1377
|
-
...turn(task.taskId, 1),
|
|
1378
|
-
request: {
|
|
1379
|
-
text: 'relax destination',
|
|
1380
|
-
approval
|
|
1381
|
-
}
|
|
1382
|
-
}), /approval_not_presented/);
|
|
1383
|
-
const awaitingBeforeBatch = runtime.resumeTask(task.taskId)?.awaitingApproval;
|
|
1384
|
-
assert.ok(awaitingBeforeBatch);
|
|
1385
|
-
const approvalPresentationKey = runtime.approvalPresentationRequestKey(task.taskId);
|
|
1386
|
-
runtime.ensureApprovalOffered(task.taskId, task.contextRef, awaitingBeforeBatch, approvalPresentationKey);
|
|
1387
|
-
assert.equal(runtime.resumeTask(task.taskId)?.awaitingApproval?.optionsEmitted, false);
|
|
1388
|
-
assert.throws(()=>runtime.startTask({
|
|
1389
|
-
...turn(task.taskId, 1),
|
|
1390
|
-
request: {
|
|
1391
|
-
text: 'relax destination',
|
|
1392
|
-
approval
|
|
1393
|
-
}
|
|
1394
|
-
}), /approval_not_presented/);
|
|
1395
|
-
assert.throws(()=>runtime.commitDecisionBatch(task.taskId, 'turn:unrelated-turn', [
|
|
1396
|
-
approvalQuestion
|
|
1397
|
-
]), /approval_presentation_key_mismatch/);
|
|
1398
|
-
assert.throws(()=>runtime.commitDecisionBatch(task.taskId, `approval:other-task:turn-1:action-v2:${'c'.repeat(64)}`, [
|
|
1399
|
-
approvalQuestion
|
|
1400
|
-
]), /approval_presentation_key_mismatch/);
|
|
1401
|
-
const alteredSourceTurnQuestion = approvalQuestion.kind === 'question' ? {
|
|
1402
|
-
...approvalQuestion,
|
|
1403
|
-
question: {
|
|
1404
|
-
...approvalQuestion.question,
|
|
1405
|
-
approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
|
|
1406
|
-
approval: {
|
|
1407
|
-
...option,
|
|
1408
|
-
sourceTurnId: 'turn-forged'
|
|
1409
|
-
}
|
|
1410
|
-
}))
|
|
1411
|
-
}
|
|
1412
|
-
} : approvalQuestion;
|
|
1413
|
-
assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
|
|
1414
|
-
alteredSourceTurnQuestion
|
|
1415
|
-
]), /approval_presentation_key_mismatch/);
|
|
1416
|
-
const alteredReceiptQuestion = approvalQuestion.kind === 'question' ? {
|
|
1417
|
-
...approvalQuestion,
|
|
1418
|
-
question: {
|
|
1419
|
-
...approvalQuestion.question,
|
|
1420
|
-
approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
|
|
1421
|
-
approval: {
|
|
1422
|
-
...option,
|
|
1423
|
-
sourceReceiptDigest: 'd'.repeat(64)
|
|
1424
|
-
}
|
|
1425
|
-
}))
|
|
1426
|
-
}
|
|
1427
|
-
} : approvalQuestion;
|
|
1428
|
-
assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
|
|
1429
|
-
alteredReceiptQuestion
|
|
1430
|
-
]), /approval_presentation_key_mismatch/);
|
|
1431
|
-
const alteredOptionQuestion = approvalQuestion.kind === 'question' ? {
|
|
1432
|
-
...approvalQuestion,
|
|
1433
|
-
question: {
|
|
1434
|
-
...approvalQuestion.question,
|
|
1435
|
-
approvalOptions: approvalQuestion.question.approvalOptions.map(({ approval: option })=>({
|
|
1436
|
-
approval: {
|
|
1437
|
-
...option,
|
|
1438
|
-
optionDigest: 'e'.repeat(64)
|
|
1439
|
-
}
|
|
1440
|
-
}))
|
|
1441
|
-
}
|
|
1442
|
-
} : approvalQuestion;
|
|
1443
|
-
assert.throws(()=>runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
|
|
1444
|
-
alteredOptionQuestion
|
|
1445
|
-
]), /approval_presentation_key_mismatch/);
|
|
1446
|
-
runtime.commitDecisionBatch(task.taskId, approvalPresentationKey, [
|
|
1447
|
-
approvalQuestion
|
|
1448
|
-
]);
|
|
1449
|
-
assert.equal(runtime.resumeTask(task.taskId)?.awaitingApproval?.optionsEmitted, true, 'question batch is the presentation authority');
|
|
1450
|
-
assert.throws(()=>runtime.startTask({
|
|
1451
|
-
...turn(task.taskId, 1),
|
|
1452
|
-
request: {
|
|
1453
|
-
text: 'relax destination',
|
|
1454
|
-
approval: {
|
|
1455
|
-
...approval,
|
|
1456
|
-
deliveryNonce: 'forged-delivery-nonce'
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
}), /approval_mismatch/);
|
|
1460
|
-
runtime.startTask({
|
|
1461
|
-
...turn(task.taskId, 1),
|
|
1462
|
-
request: {
|
|
1463
|
-
text: 'relax destination',
|
|
1464
|
-
approval
|
|
1465
|
-
}
|
|
1466
|
-
});
|
|
1467
|
-
const approvalEventCount = runtime['ledger'].countEvents();
|
|
1468
|
-
runtime.startTask({
|
|
1469
|
-
...turn(task.taskId, 1),
|
|
1470
|
-
request: {
|
|
1471
|
-
text: 'relax destination',
|
|
1472
|
-
approval
|
|
1473
|
-
}
|
|
1474
|
-
});
|
|
1475
|
-
assert.equal(runtime['ledger'].countEvents(), approvalEventCount, 'identical durable approval retry is idempotent');
|
|
1476
|
-
assert.throws(()=>runtime.startTask({
|
|
1477
|
-
...turn(task.taskId, 1),
|
|
1478
|
-
turnId: 'tampered-approval-turn',
|
|
1479
|
-
request: {
|
|
1480
|
-
text: 'relax destination',
|
|
1481
|
-
approval: {
|
|
1482
|
-
...approval,
|
|
1483
|
-
to: 'drop'
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
}), /approval_mismatch/);
|
|
1487
|
-
const expiredRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(stateRoot), {
|
|
1488
|
-
now: ()=>'2026-09-01T10:03:00.000Z'
|
|
1489
|
-
});
|
|
1490
|
-
assert.throws(()=>expiredRuntime.issueOperation(task.taskId, action('action-v2-expired', 1)), /approval_expired/);
|
|
1491
|
-
const approvedOperation = runtime.issueOperation(task.taskId, action('action-v2-relaxed', 1));
|
|
1492
|
-
assert.equal(approvedOperation.action.relaxationApprovalRef?.targetActionId, 'action-v2-relaxed');
|
|
1493
|
-
assert.equal(approvedOperation.action.relaxationApprovalRef?.targetActionKind, 'search.run');
|
|
1494
|
-
assert.throws(()=>runtime.issueOperation(task.taskId, action('action-v2-relaxed-again', 1)), /receipt_required/);
|
|
1495
|
-
const reopened = new BookingCopilotTaskRuntimeV2(ensureLedger(stateRoot), {
|
|
1496
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
1497
|
-
});
|
|
1498
|
-
assert.equal(reopened.resumeTask(task.taskId)?.lastReceipt?.actionId, 'action-v2');
|
|
1499
|
-
assert.equal(reopened.resumeTask(task.taskId)?.pendingAction?.actionId, 'action-v2-relaxed');
|
|
1500
|
-
assert.throws(()=>reopened.issueOperation(task.taskId, {
|
|
1501
|
-
...action('forged-ref', 1),
|
|
1502
|
-
relaxationApprovalRef: {
|
|
1503
|
-
...approvedOperation.action.relaxationApprovalRef,
|
|
1504
|
-
targetActionId: 'forged-ref'
|
|
1505
|
-
}
|
|
1506
|
-
}), /receipt_required/);
|
|
1507
|
-
const forgedTask = runtime.startTask(turn('task-forged', 1));
|
|
1508
|
-
assert.throws(()=>runtime.issueOperation(forgedTask.taskId, action('forged-ref', 1, {
|
|
1509
|
-
relaxationApprovalRef: {
|
|
1510
|
-
...approvedOperation.action.relaxationApprovalRef,
|
|
1511
|
-
targetActionId: 'forged-ref'
|
|
1512
|
-
}
|
|
1513
|
-
})), /approval_ref_planner_owned_forbidden/);
|
|
1514
|
-
const forgedSourceRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-forged-source-'));
|
|
1515
|
-
const forgedSourceRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(forgedSourceRoot), {
|
|
1516
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
1517
|
-
});
|
|
1518
|
-
const forgedSourceTask = forgedSourceRuntime.startTask(turn('task-forged-source'));
|
|
1519
|
-
forgedSourceRuntime.issueOperation(forgedSourceTask.taskId, action('action-real'));
|
|
1520
|
-
const forgedSourceReceipt = forgedSourceRuntime.withReceiptDigest({
|
|
1521
|
-
...receipt,
|
|
1522
|
-
actionId: 'action-real',
|
|
1523
|
-
resultContract: {
|
|
1524
|
-
...receipt.resultContract,
|
|
1525
|
-
blockers: [
|
|
1526
|
-
{
|
|
1527
|
-
...receipt.resultContract.blockers[0],
|
|
1528
|
-
sourceActionId: 'action-forged'
|
|
1529
|
-
}
|
|
1530
|
-
]
|
|
1531
|
-
}
|
|
1532
|
-
});
|
|
1533
|
-
assert.throws(()=>forgedSourceRuntime.continueWithReceipt({
|
|
1534
|
-
schemaVersion: 'booking.surface.v2',
|
|
1535
|
-
kind: 'action.receipt.continuation',
|
|
1536
|
-
taskId: forgedSourceTask.taskId,
|
|
1537
|
-
workspace: workspace(1),
|
|
1538
|
-
receipt: forgedSourceReceipt
|
|
1539
|
-
}), /receipt_source_action_mismatch/);
|
|
1540
|
-
assert.equal(forgedSourceRuntime.resumeTask(forgedSourceTask.taskId)?.pendingAction?.actionId, 'action-real', 'forged blocker is rejected before receipt append');
|
|
1541
|
-
const tamperRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-tamper-'));
|
|
1542
|
-
const tamperLedger = ensureLedger(tamperRoot);
|
|
1543
|
-
const tamperRuntime = new BookingCopilotTaskRuntimeV2(tamperLedger);
|
|
1544
|
-
tamperRuntime.startTask(turn('task-tamper'));
|
|
1545
|
-
tamperLedger.insertEvent({
|
|
1546
|
-
actor: 'tamper',
|
|
1547
|
-
kind: 'booking.copilot.v2.user.turn.observed',
|
|
1548
|
-
subjectId: 'task-tamper',
|
|
1549
|
-
runId: 'task-tamper',
|
|
1550
|
-
idemKey: 'tamper-turn',
|
|
1551
|
-
payload: {
|
|
1552
|
-
schema: 'booking.copilot.ledger.v2',
|
|
1553
|
-
taskId: 'task-tamper',
|
|
1554
|
-
contextRef: 'ctx-v2',
|
|
1555
|
-
requestDigest: 'tampered',
|
|
1556
|
-
approval: {
|
|
1557
|
-
forged: true
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
});
|
|
1561
|
-
assert.throws(()=>tamperRuntime.resumeTask('task-tamper'), /ledger_corrupt:task-tamper:turn_approval/);
|
|
1562
|
-
const serverRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-server-'));
|
|
1563
|
-
let serverId = 0;
|
|
1564
|
-
const serverRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(serverRoot), {
|
|
1565
|
-
idFactory: (prefix)=>`${prefix}-server-${++serverId}`,
|
|
1566
|
-
contextRefFactory: ()=>'ctx-server'
|
|
1567
|
-
});
|
|
1568
|
-
let factoryCalls = 0;
|
|
1569
|
-
let plannerCalls = 0;
|
|
1570
|
-
const server = await startBookingCopilotServer({
|
|
1571
|
-
apiKey: 'v2-server-key',
|
|
1572
|
-
runtime: new BookingCopilotTaskRuntime(ensureLedger(serverRoot)),
|
|
1573
|
-
plannerFactory: (initial)=>({
|
|
1574
|
-
next: async ()=>[]
|
|
1575
|
-
}),
|
|
1576
|
-
v2: {
|
|
1577
|
-
runtime: serverRuntime,
|
|
1578
|
-
principal: {
|
|
1579
|
-
subject: 'bff-principal-a',
|
|
1580
|
-
scope: 'booking:read'
|
|
1581
|
-
},
|
|
1582
|
-
ingressBinding: {
|
|
1583
|
-
bind: ()=>({
|
|
1584
|
-
taskId: 'task-server',
|
|
1585
|
-
turnId: 'ingress-turn-1',
|
|
1586
|
-
contextRef: 'ctx-server',
|
|
1587
|
-
surface: 'tenant',
|
|
1588
|
-
allowedActions: [
|
|
1589
|
-
...BOOKING_READ_ACTION_KINDS_V2
|
|
1590
|
-
]
|
|
1591
|
-
})
|
|
1592
|
-
},
|
|
1593
|
-
plannerFactory: (initial)=>{
|
|
1594
|
-
factoryCalls++;
|
|
1595
|
-
return {
|
|
1596
|
-
next: async ({ task: current })=>{
|
|
1597
|
-
plannerCalls++;
|
|
1598
|
-
const decision = {
|
|
1599
|
-
kind: 'operation',
|
|
1600
|
-
action: {
|
|
1601
|
-
...action(`server-action-${plannerCalls}`, current.revision),
|
|
1602
|
-
contextRef: current.contextRef
|
|
1603
|
-
}
|
|
1604
|
-
};
|
|
1605
|
-
return [
|
|
1606
|
-
decision
|
|
1607
|
-
];
|
|
1608
|
-
}
|
|
1609
|
-
};
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
});
|
|
1613
|
-
const endpoint = `http://127.0.0.1:${server.port}/a2a/booking-copilot/turn`;
|
|
1614
|
-
const headers = {
|
|
1615
|
-
authorization: 'Bearer v2-server-key',
|
|
1616
|
-
'content-type': 'application/json',
|
|
1617
|
-
'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION_V2,
|
|
1618
|
-
'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_V2_SHA256
|
|
1619
|
-
};
|
|
1620
|
-
const ingress = {
|
|
1621
|
-
schemaVersion: 'booking.surface.v2',
|
|
1622
|
-
kind: 'user.turn.ingress',
|
|
1623
|
-
requestKey: 'ingress-request-1',
|
|
1624
|
-
taskHandle: 'opaque-task-handle-1',
|
|
1625
|
-
surfaceHint: 'tenant',
|
|
1626
|
-
workspace: {
|
|
1627
|
-
schemaVersion: 'booking.surface.v2',
|
|
1628
|
-
revision: 0,
|
|
1629
|
-
locale: 'en-US',
|
|
1630
|
-
currency: 'AED',
|
|
1631
|
-
searchDraft: {},
|
|
1632
|
-
results: {
|
|
1633
|
-
status: 'idle'
|
|
1634
|
-
},
|
|
1635
|
-
visibleHotels: [],
|
|
1636
|
-
loadedOffers: [],
|
|
1637
|
-
shortlistedOfferRefs: []
|
|
1638
|
-
},
|
|
1639
|
-
request: {
|
|
1640
|
-
text: 'find hotels in Dubai'
|
|
1641
|
-
}
|
|
1642
|
-
};
|
|
1643
|
-
const missingTurnLedgerEvents = serverRuntime['ledger'].countEvents();
|
|
1644
|
-
const missingIngress = await fetch(endpoint, {
|
|
1645
|
-
method: 'POST',
|
|
1646
|
-
headers,
|
|
1647
|
-
body: JSON.stringify({
|
|
1648
|
-
...ingress,
|
|
1649
|
-
requestKey: undefined
|
|
1650
|
-
})
|
|
1651
|
-
});
|
|
1652
|
-
assert.equal(missingIngress.status, 400, 'missing ingress requestKey is rejected at HTTP schema boundary');
|
|
1653
|
-
const missingUser = await fetch(endpoint, {
|
|
1654
|
-
method: 'POST',
|
|
1655
|
-
headers,
|
|
1656
|
-
body: JSON.stringify({
|
|
1657
|
-
...turn('missing-user'),
|
|
1658
|
-
turnId: undefined
|
|
1659
|
-
})
|
|
1660
|
-
});
|
|
1661
|
-
assert.equal(missingUser.status, 400, 'missing user turnId is rejected at HTTP schema boundary');
|
|
1662
|
-
const injectedIngressIdentity = await fetch(endpoint, {
|
|
1663
|
-
method: 'POST',
|
|
1664
|
-
headers,
|
|
1665
|
-
body: JSON.stringify({
|
|
1666
|
-
...ingress,
|
|
1667
|
-
taskId: 'browser-task',
|
|
1668
|
-
turnId: 'browser-turn',
|
|
1669
|
-
contextRef: null
|
|
1670
|
-
})
|
|
1671
|
-
});
|
|
1672
|
-
assert.equal(injectedIngressIdentity.status, 400, 'browser-supplied identity is rejected at HTTP schema boundary');
|
|
1673
|
-
const missingUserTask = await fetch(endpoint, {
|
|
1674
|
-
method: 'POST',
|
|
1675
|
-
headers,
|
|
1676
|
-
body: JSON.stringify({
|
|
1677
|
-
...turn('missing-user-task'),
|
|
1678
|
-
taskId: undefined
|
|
1679
|
-
})
|
|
1680
|
-
});
|
|
1681
|
-
assert.equal(missingUserTask.status, 400, 'missing user taskId is rejected at HTTP schema boundary');
|
|
1682
|
-
assert.equal(serverRuntime['ledger'].countEvents(), missingTurnLedgerEvents, 'missing taskId/turnId requests have no ledger side effects');
|
|
1683
|
-
assert.equal(plannerCalls, 0, 'missing taskId/turnId requests do not call planner');
|
|
1684
|
-
const first = await fetch(endpoint, {
|
|
1685
|
-
method: 'POST',
|
|
1686
|
-
headers,
|
|
1687
|
-
body: JSON.stringify(ingress)
|
|
1688
|
-
});
|
|
1689
|
-
assert.equal(first.status, 200);
|
|
1690
|
-
const firstBody = await first.text();
|
|
1691
|
-
assert.match(firstBody, /event: operation/);
|
|
1692
|
-
assert.equal(factoryCalls, 1);
|
|
1693
|
-
assert.equal(plannerCalls, 1);
|
|
1694
|
-
assert.match(firstBody, /ctx-server/);
|
|
1695
|
-
const ingressReplayWhileWaiting = await fetch(endpoint, {
|
|
1696
|
-
method: 'POST',
|
|
1697
|
-
headers,
|
|
1698
|
-
body: JSON.stringify(ingress)
|
|
1699
|
-
});
|
|
1700
|
-
assert.equal(ingressReplayWhileWaiting.status, 200, 'stable ingress turn identity replays while waiting for receipt');
|
|
1701
|
-
assert.equal(await ingressReplayWhileWaiting.text(), firstBody);
|
|
1702
|
-
assert.equal(plannerCalls, 1);
|
|
1703
|
-
const pending = serverRuntime.resumeTask('task-server')?.pendingAction;
|
|
1704
|
-
assert.ok(pending);
|
|
1705
|
-
const continuationWorkspace = {
|
|
1706
|
-
...workspace(1),
|
|
1707
|
-
contextRef: 'ctx-server'
|
|
1708
|
-
};
|
|
1709
|
-
const postActionWorkspace = {
|
|
1710
|
-
...continuationWorkspace,
|
|
1711
|
-
visibleHotels: [
|
|
1712
|
-
{
|
|
1713
|
-
hotelRef: 'hotel-a',
|
|
1714
|
-
name: 'Hotel A',
|
|
1715
|
-
factRefs: [
|
|
1716
|
-
'fact-hotel-a'
|
|
1717
|
-
]
|
|
1718
|
-
}
|
|
1719
|
-
]
|
|
1720
|
-
};
|
|
1721
|
-
const receiptTurn = {
|
|
1722
|
-
schemaVersion: 'booking.surface.v2',
|
|
1723
|
-
kind: 'action.receipt.continuation',
|
|
1724
|
-
taskId: 'task-server',
|
|
1725
|
-
workspace: postActionWorkspace,
|
|
1726
|
-
receipt: {
|
|
1727
|
-
schemaVersion: 'booking.surface.v2',
|
|
1728
|
-
kind: 'action.receipt',
|
|
1729
|
-
actionId: pending.actionId,
|
|
1730
|
-
contextRef: 'ctx-server',
|
|
1731
|
-
status: 'applied',
|
|
1732
|
-
revision: 1,
|
|
1733
|
-
observation: {
|
|
1734
|
-
kind: 'search.state',
|
|
1735
|
-
resultCount: 1
|
|
1736
|
-
},
|
|
1737
|
-
resultContract: {
|
|
1738
|
-
outcome: 'complete',
|
|
1739
|
-
hardCriteriaMet: true,
|
|
1740
|
-
factRefs: [],
|
|
1741
|
-
gapCodes: [],
|
|
1742
|
-
blockers: [],
|
|
1743
|
-
relaxationsApplied: []
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
};
|
|
1747
|
-
const [second, concurrentSecond] = await Promise.all([
|
|
1748
|
-
fetch(endpoint, {
|
|
1749
|
-
method: 'POST',
|
|
1750
|
-
headers,
|
|
1751
|
-
body: JSON.stringify(receiptTurn)
|
|
1752
|
-
}),
|
|
1753
|
-
fetch(endpoint, {
|
|
1754
|
-
method: 'POST',
|
|
1755
|
-
headers,
|
|
1756
|
-
body: JSON.stringify(receiptTurn)
|
|
1757
|
-
})
|
|
1758
|
-
]);
|
|
1759
|
-
assert.equal(second.status, 200);
|
|
1760
|
-
assert.equal(concurrentSecond.status, 200);
|
|
1761
|
-
const secondBody = await second.text();
|
|
1762
|
-
const concurrentSecondBody = await concurrentSecond.text();
|
|
1763
|
-
assert.equal(concurrentSecondBody, secondBody, 'concurrent identical receipt requests receive the same durable typed batch');
|
|
1764
|
-
assert.equal(plannerCalls, 2);
|
|
1765
|
-
const replay = await fetch(endpoint, {
|
|
1766
|
-
method: 'POST',
|
|
1767
|
-
headers,
|
|
1768
|
-
body: JSON.stringify(receiptTurn)
|
|
1769
|
-
});
|
|
1770
|
-
assert.equal(replay.status, 200);
|
|
1771
|
-
const replayBody = await replay.text();
|
|
1772
|
-
assert.equal(plannerCalls, 2, 'identical durable receipt replay does not call planner again');
|
|
1773
|
-
assert.equal(replayBody, secondBody, 'receipt replay emits the exact durable SSE batch');
|
|
1774
|
-
const alteredReceiptWorkspace = await fetch(endpoint, {
|
|
1775
|
-
method: 'POST',
|
|
1776
|
-
headers,
|
|
1777
|
-
body: JSON.stringify({
|
|
1778
|
-
...receiptTurn,
|
|
1779
|
-
workspace: {
|
|
1780
|
-
...continuationWorkspace,
|
|
1781
|
-
currency: 'USD'
|
|
1782
|
-
}
|
|
1783
|
-
})
|
|
1784
|
-
});
|
|
1785
|
-
assert.equal(alteredReceiptWorkspace.status, 409, 'receipt replay binds the full workspace semantics');
|
|
1786
|
-
const alteredReceiptDraft = await fetch(endpoint, {
|
|
1787
|
-
method: 'POST',
|
|
1788
|
-
headers,
|
|
1789
|
-
body: JSON.stringify({
|
|
1790
|
-
...receiptTurn,
|
|
1791
|
-
workspace: {
|
|
1792
|
-
...postActionWorkspace,
|
|
1793
|
-
searchDraft: {
|
|
1794
|
-
destination: {
|
|
1795
|
-
query: 'Abu Dhabi'
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
})
|
|
1800
|
-
});
|
|
1801
|
-
assert.equal(alteredReceiptDraft.status, 409, 'receipt rejects unauthorized non-action workspace mutation');
|
|
1802
|
-
const pendingWithBlocker = serverRuntime.resumeTask('task-server')?.pendingAction;
|
|
1803
|
-
assert.ok(pendingWithBlocker);
|
|
1804
|
-
const blockerReceipt = serverRuntime.withReceiptDigest({
|
|
1805
|
-
...receipt,
|
|
1806
|
-
actionId: pendingWithBlocker.actionId,
|
|
1807
|
-
contextRef: 'ctx-server',
|
|
1808
|
-
revision: 2,
|
|
1809
|
-
resultContract: {
|
|
1810
|
-
...receipt.resultContract,
|
|
1811
|
-
blockers: [
|
|
1812
|
-
{
|
|
1813
|
-
...receipt.resultContract.blockers[0],
|
|
1814
|
-
sourceActionId: pendingWithBlocker.actionId
|
|
1815
|
-
}
|
|
1816
|
-
]
|
|
1817
|
-
}
|
|
1818
|
-
});
|
|
1819
|
-
const blockerTurn = {
|
|
1820
|
-
...receiptTurn,
|
|
1821
|
-
workspace: {
|
|
1822
|
-
...continuationWorkspace,
|
|
1823
|
-
revision: 2
|
|
1824
|
-
},
|
|
1825
|
-
receipt: blockerReceipt
|
|
1826
|
-
};
|
|
1827
|
-
const blockerResponse = await fetch(endpoint, {
|
|
1828
|
-
method: 'POST',
|
|
1829
|
-
headers,
|
|
1830
|
-
body: JSON.stringify(blockerTurn)
|
|
1831
|
-
});
|
|
1832
|
-
assert.equal(blockerResponse.status, 200);
|
|
1833
|
-
const blockerBody = await blockerResponse.text();
|
|
1834
|
-
assert.match(blockerBody, /relaxation_approval_required/);
|
|
1835
|
-
assert.equal(plannerCalls, 2, 'runtime-owned approval question does not call DSH planner');
|
|
1836
|
-
const questionApproval = serverRuntime.approvalQuestion('task-server');
|
|
1837
|
-
assert.equal(questionApproval.kind, 'question');
|
|
1838
|
-
const deliveredQuestionMatch = /event: question\ndata: ([^\n]+)/.exec(blockerBody);
|
|
1839
|
-
assert.ok(deliveredQuestionMatch, 'question SSE must be delivered before approval');
|
|
1840
|
-
const deliveredQuestion = JSON.parse(deliveredQuestionMatch[1]);
|
|
1841
|
-
const approved = questionApproval.kind === 'question' ? questionApproval.question.approvalOptions[0].approval : undefined;
|
|
1842
|
-
assert.ok(approved);
|
|
1843
|
-
assert.equal(deliveredQuestion.question.approvalOptions[0].approval.deliveryNonce, approved.deliveryNonce, 'SSE carries the durable unpredictable presentation nonce');
|
|
1844
|
-
const approvedTurn = {
|
|
1845
|
-
schemaVersion: 'booking.surface.v2',
|
|
1846
|
-
kind: 'user.turn',
|
|
1847
|
-
taskId: 'task-server',
|
|
1848
|
-
turnId: 'approval-turn-1',
|
|
1849
|
-
workspace: {
|
|
1850
|
-
...continuationWorkspace,
|
|
1851
|
-
revision: 2
|
|
1852
|
-
},
|
|
1853
|
-
request: {
|
|
1854
|
-
text: 'prefer a nearby match',
|
|
1855
|
-
approval: approved
|
|
1856
|
-
}
|
|
1857
|
-
};
|
|
1858
|
-
const approvedResponse = await fetch(endpoint, {
|
|
1859
|
-
method: 'POST',
|
|
1860
|
-
headers,
|
|
1861
|
-
body: JSON.stringify(approvedTurn)
|
|
1862
|
-
});
|
|
1863
|
-
assert.equal(approvedResponse.status, 200);
|
|
1864
|
-
const approvedBody = await approvedResponse.text();
|
|
1865
|
-
assert.equal(plannerCalls, 3);
|
|
1866
|
-
const crashRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-crash-replay-'));
|
|
1867
|
-
const crashLedger = ensureLedger(crashRoot);
|
|
1868
|
-
const crashV2 = new BookingCopilotTaskRuntimeV2(crashLedger);
|
|
1869
|
-
const crashTurn = {
|
|
1870
|
-
...turn('task-crash'),
|
|
1871
|
-
turnId: 'stable-turn-1'
|
|
1872
|
-
};
|
|
1873
|
-
crashV2.startTask(crashTurn);
|
|
1874
|
-
const crashCount = crashLedger.countEvents();
|
|
1875
|
-
crashV2.startTask(crashTurn);
|
|
1876
|
-
assert.equal(crashLedger.countEvents(), crashCount, 'stable turn identity retries after startTask without ledger delta');
|
|
1877
|
-
const duplicateTextTurn = {
|
|
1878
|
-
...turn('task-crash'),
|
|
1879
|
-
turnId: 'stable-turn-2'
|
|
1880
|
-
};
|
|
1881
|
-
crashV2.startTask(duplicateTextTurn);
|
|
1882
|
-
assert.equal(crashV2.resumeTask('task-crash')?.userTurnCount, 2, 'same text with a new turn identity is a new turn');
|
|
1883
|
-
const atomicRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-binding-atomic-'));
|
|
1884
|
-
const atomicLedger = ensureLedger(atomicRoot);
|
|
1885
|
-
const atomicRuntime = new BookingCopilotTaskRuntimeV2(atomicLedger);
|
|
1886
|
-
const atomicTurn = turn('task-atomic-binding');
|
|
1887
|
-
const atomicBinding = {
|
|
1888
|
-
requestKey: 'atomic-request-1',
|
|
1889
|
-
principal: {
|
|
1890
|
-
subject: 'bff-atomic',
|
|
1891
|
-
scope: 'booking:read'
|
|
1892
|
-
},
|
|
1893
|
-
taskHandle: 'atomic-task-handle'
|
|
1894
|
-
};
|
|
1895
|
-
atomicLedger.db.exec("CREATE TRIGGER abort_booking_request_binding BEFORE INSERT ON events WHEN NEW.kind = 'booking.copilot.v2.request.binding' BEGIN SELECT RAISE(ABORT, 'crash_after_start_before_binding'); END");
|
|
1896
|
-
assert.throws(()=>atomicRuntime.startTask(atomicTurn, atomicBinding), /crash_after_start_before_binding|SQLITE_CONSTRAINT/);
|
|
1897
|
-
assert.equal(atomicLedger.countEvents(), 0, 'binding failure rolls back STARTED and TURN reservation');
|
|
1898
|
-
atomicLedger.db.exec('DROP TRIGGER abort_booking_request_binding');
|
|
1899
|
-
atomicRuntime.startTask(atomicTurn, atomicBinding);
|
|
1900
|
-
const atomicCount = atomicLedger.countEvents();
|
|
1901
|
-
atomicRuntime.startTask(atomicTurn, atomicBinding);
|
|
1902
|
-
assert.equal(atomicLedger.countEvents(), atomicCount, 'same request binding retry does not create a second task/planner turn');
|
|
1903
|
-
assert.equal(atomicLedger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.v2.request.binding'").get().n, 1);
|
|
1904
|
-
assert.throws(()=>atomicRuntime.startTask(atomicTurn, {
|
|
1905
|
-
...atomicBinding,
|
|
1906
|
-
principal: {
|
|
1907
|
-
subject: 'other-principal',
|
|
1908
|
-
scope: 'booking:read'
|
|
1909
|
-
}
|
|
1910
|
-
}), /principal_conflict|request_conflict/, 'taskHandle cannot be rebound across principals');
|
|
1911
|
-
assert.throws(()=>atomicRuntime.startTask(turn('task-atomic-binding-other'), {
|
|
1912
|
-
...atomicBinding,
|
|
1913
|
-
requestKey: 'atomic-request-2'
|
|
1914
|
-
}), /task_handle_conflict/, 'taskHandle cannot bind a second task in the same principal scope');
|
|
1915
|
-
atomicLedger.close();
|
|
1916
|
-
rmSync(atomicRoot, {
|
|
1917
|
-
recursive: true,
|
|
1918
|
-
force: true
|
|
1919
|
-
});
|
|
1920
|
-
const v1AfterV2 = new BookingCopilotTaskRuntime(crashLedger);
|
|
1921
|
-
const v1Turn = {
|
|
1922
|
-
schemaVersion: 'booking.surface.v1',
|
|
1923
|
-
kind: 'user.turn',
|
|
1924
|
-
taskId: 'task-cross-version',
|
|
1925
|
-
workspace: {
|
|
1926
|
-
schemaVersion: 'booking.surface.v1',
|
|
1927
|
-
contextRef: 'ctx-v1',
|
|
1928
|
-
surface: 'tenant',
|
|
1929
|
-
revision: 0,
|
|
1930
|
-
locale: 'en-US',
|
|
1931
|
-
currency: 'AED',
|
|
1932
|
-
searchDraft: {},
|
|
1933
|
-
results: {
|
|
1934
|
-
status: 'idle'
|
|
1935
|
-
},
|
|
1936
|
-
visibleHotels: [],
|
|
1937
|
-
loadedOffers: [],
|
|
1938
|
-
shortlistedOfferRefs: [],
|
|
1939
|
-
capabilities: {
|
|
1940
|
-
surface: 'tenant',
|
|
1941
|
-
allowedActions: [
|
|
1942
|
-
'search.run'
|
|
1943
|
-
]
|
|
1944
|
-
}
|
|
1945
|
-
},
|
|
1946
|
-
request: {
|
|
1947
|
-
text: 'v1 cross-version test'
|
|
1948
|
-
}
|
|
1949
|
-
};
|
|
1950
|
-
const crossV2 = new BookingCopilotTaskRuntimeV2(crashLedger);
|
|
1951
|
-
crossV2.startTask({
|
|
1952
|
-
...turn('task-cross-version'),
|
|
1953
|
-
workspace: {
|
|
1954
|
-
...workspace(),
|
|
1955
|
-
contextRef: 'ctx-v2'
|
|
1956
|
-
}
|
|
1957
|
-
});
|
|
1958
|
-
assert.throws(()=>v1AfterV2.startTask(v1Turn), /task_conflict:protocol_version/);
|
|
1959
|
-
const reverseRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-reverse-version-'));
|
|
1960
|
-
const reverseLedger = ensureLedger(reverseRoot);
|
|
1961
|
-
const reverseV1 = new BookingCopilotTaskRuntime(reverseLedger);
|
|
1962
|
-
reverseV1.startTask(v1Turn);
|
|
1963
|
-
const reverseV2 = new BookingCopilotTaskRuntimeV2(reverseLedger);
|
|
1964
|
-
assert.throws(()=>reverseV2.startTask({
|
|
1965
|
-
...turn('task-cross-version'),
|
|
1966
|
-
workspace: {
|
|
1967
|
-
...workspace(),
|
|
1968
|
-
contextRef: 'ctx-v1'
|
|
1969
|
-
}
|
|
1970
|
-
}), /task_conflict:protocol_version/);
|
|
1971
|
-
const terminalRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-terminal-replay-'));
|
|
1972
|
-
const terminalLedger = ensureLedger(terminalRoot);
|
|
1973
|
-
const terminalRuntime = new BookingCopilotTaskRuntimeV2(terminalLedger);
|
|
1974
|
-
const terminalTurn = {
|
|
1975
|
-
...turn('task-terminal'),
|
|
1976
|
-
turnId: 'terminal-turn-1'
|
|
1977
|
-
};
|
|
1978
|
-
terminalRuntime.startTask(terminalTurn);
|
|
1979
|
-
terminalRuntime.applyDecisionBatch('task-terminal', 'turn:task-terminal:terminal-turn-1', [
|
|
1980
|
-
{
|
|
1981
|
-
kind: 'terminal',
|
|
1982
|
-
terminal: {
|
|
1983
|
-
status: 'completed',
|
|
1984
|
-
summary: 'read-only result',
|
|
1985
|
-
factRefs: []
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
], true);
|
|
1989
|
-
assert.equal(terminalRuntime.resumeTask('task-terminal')?.phase, 'terminal');
|
|
1990
|
-
const terminalCount = terminalLedger.countEvents();
|
|
1991
|
-
assert.throws(()=>terminalRuntime.startTask(terminalTurn), /task_terminal/);
|
|
1992
|
-
assert.equal(terminalLedger.countEvents(), terminalCount, 'terminal exact replay does not append a turn');
|
|
1993
|
-
assert.throws(()=>terminalRuntime.startTask({
|
|
1994
|
-
...terminalTurn,
|
|
1995
|
-
turnId: 'terminal-new-turn'
|
|
1996
|
-
}), /task_terminal/);
|
|
1997
|
-
const finalityRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-batch-finality-'));
|
|
1998
|
-
const finalityLedger = ensureLedger(finalityRoot);
|
|
1999
|
-
const finalityRuntime = new BookingCopilotTaskRuntimeV2(finalityLedger);
|
|
2000
|
-
finalityRuntime.startTask(turn('task-batch-finality'));
|
|
2001
|
-
const finalityRows = finalityLedger.countEvents();
|
|
2002
|
-
const finalityBytes = finalityLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n;
|
|
2003
|
-
const finalDecision = {
|
|
2004
|
-
kind: 'terminal',
|
|
2005
|
-
terminal: {
|
|
2006
|
-
status: 'completed',
|
|
2007
|
-
summary: 'one final',
|
|
2008
|
-
factRefs: []
|
|
2009
|
-
}
|
|
2010
|
-
};
|
|
2011
|
-
assert.throws(()=>finalityRuntime.applyDecisionBatch('task-batch-finality', 'batch-two-finals', [
|
|
2012
|
-
finalDecision,
|
|
2013
|
-
finalDecision
|
|
2014
|
-
], true), /decision_batch_finality/);
|
|
2015
|
-
assert.throws(()=>finalityRuntime.applyDecisionBatch('task-batch-finality', 'batch-after-final', [
|
|
2016
|
-
finalDecision,
|
|
2017
|
-
{
|
|
2018
|
-
kind: 'explanation',
|
|
2019
|
-
explanation: {
|
|
2020
|
-
text: 'must not follow final',
|
|
2021
|
-
factRefs: []
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
], true), /decision_batch_finality/);
|
|
2025
|
-
assert.equal(finalityLedger.countEvents(), finalityRows, 'malformed final batches emit no events');
|
|
2026
|
-
assert.equal(finalityLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n, finalityBytes, 'malformed final batches do not alter payload bytes');
|
|
2027
|
-
finalityLedger.close();
|
|
2028
|
-
rmSync(finalityRoot, {
|
|
2029
|
-
recursive: true,
|
|
2030
|
-
force: true
|
|
2031
|
-
});
|
|
2032
|
-
const longRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-internal-key-'));
|
|
2033
|
-
const longLedger = ensureLedger(longRoot);
|
|
2034
|
-
const longRuntime = new BookingCopilotTaskRuntimeV2(longLedger);
|
|
2035
|
-
const longTaskId = `task-${'t'.repeat(180)}`;
|
|
2036
|
-
const longTurnId = `turn-${'u'.repeat(180)}`;
|
|
2037
|
-
const longActionId = `action-${'a'.repeat(180)}`;
|
|
2038
|
-
longRuntime.startTask({
|
|
2039
|
-
...turn(longTaskId),
|
|
2040
|
-
turnId: longTurnId
|
|
2041
|
-
});
|
|
2042
|
-
const longDecisionKey = `turn:${longTaskId}:${longTurnId}:${longActionId}`;
|
|
2043
|
-
const longDecisionEvents = longRuntime.applyDecisionBatch(longTaskId, longDecisionKey, [
|
|
2044
|
-
{
|
|
2045
|
-
kind: 'operation',
|
|
2046
|
-
action: action(longActionId)
|
|
2047
|
-
}
|
|
2048
|
-
]);
|
|
2049
|
-
assert.equal(longDecisionEvents.some((event)=>event.kind === 'operation' && event.action.actionId === longActionId), true, 'long internal decision key is accepted');
|
|
2050
|
-
longLedger.close();
|
|
2051
|
-
rmSync(longRoot, {
|
|
2052
|
-
recursive: true,
|
|
2053
|
-
force: true
|
|
2054
|
-
});
|
|
2055
|
-
const budgetRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-operation-budget-'));
|
|
2056
|
-
const budgetLedger = ensureLedger(budgetRoot);
|
|
2057
|
-
const budgetRuntime = new BookingCopilotTaskRuntimeV2(budgetLedger);
|
|
2058
|
-
const budgetTaskId = 'task-operation-budget';
|
|
2059
|
-
budgetRuntime.startTask(turn(budgetTaskId));
|
|
2060
|
-
let twentiethContinuation;
|
|
2061
|
-
for(let ordinal = 1; ordinal <= 20; ordinal++){
|
|
2062
|
-
const currentTurn = {
|
|
2063
|
-
...turn(budgetTaskId),
|
|
2064
|
-
turnId: `budget-turn-${ordinal}`
|
|
2065
|
-
};
|
|
2066
|
-
if (ordinal > 1) budgetRuntime.startTask(currentTurn);
|
|
2067
|
-
const operation = budgetRuntime.issueOperation(budgetTaskId, action(`budget-action-${ordinal}`));
|
|
2068
|
-
const continuation = {
|
|
2069
|
-
schemaVersion: 'booking.surface.v2',
|
|
2070
|
-
kind: 'action.receipt.continuation',
|
|
2071
|
-
taskId: budgetTaskId,
|
|
2072
|
-
workspace: workspace(0),
|
|
2073
|
-
receipt: budgetRuntime.withReceiptDigest({
|
|
2074
|
-
schemaVersion: 'booking.surface.v2',
|
|
2075
|
-
kind: 'action.receipt',
|
|
2076
|
-
actionId: operation.action.actionId,
|
|
2077
|
-
contextRef: 'ctx-v2',
|
|
2078
|
-
status: 'applied',
|
|
2079
|
-
revision: 0,
|
|
2080
|
-
observation: {
|
|
2081
|
-
kind: 'search.state',
|
|
2082
|
-
resultCount: 0
|
|
2083
|
-
},
|
|
2084
|
-
resultContract: {
|
|
2085
|
-
outcome: 'complete',
|
|
2086
|
-
hardCriteriaMet: true,
|
|
2087
|
-
factRefs: [],
|
|
2088
|
-
gapCodes: [],
|
|
2089
|
-
blockers: [],
|
|
2090
|
-
relaxationsApplied: []
|
|
2091
|
-
}
|
|
2092
|
-
})
|
|
2093
|
-
};
|
|
2094
|
-
const folded = budgetRuntime.continueWithReceipt(continuation);
|
|
2095
|
-
assert.equal(folded.operationCount, ordinal);
|
|
2096
|
-
if (ordinal === 19) assert.equal(folded.phase, 'planning', '19th receipt remains continuable');
|
|
2097
|
-
if (ordinal === 20) {
|
|
2098
|
-
assert.equal(folded.phase, 'terminal');
|
|
2099
|
-
twentiethContinuation = continuation;
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
assert.equal(budgetRuntime.resumeTask(budgetTaskId)?.operationCount, 20);
|
|
2103
|
-
assert.equal(budgetLedger.db.prepare("SELECT COUNT(*) AS n FROM events WHERE kind = 'booking.copilot.v2.event.emitted' AND json_extract(payload, '$.eventKind') = 'terminal'").get().n, 1, '20th receipt emits one durable terminal event');
|
|
2104
|
-
const terminalBatchCount = budgetLedger.countEvents();
|
|
2105
|
-
const terminalBatchBytes = budgetLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n;
|
|
2106
|
-
const budgetRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(budgetRoot));
|
|
2107
|
-
assert.equal(budgetRestart.resumeTask(budgetTaskId)?.operationCount, 20, 'operation count survives restart');
|
|
2108
|
-
assert.throws(()=>budgetRestart.startTask({
|
|
2109
|
-
...turn(budgetTaskId),
|
|
2110
|
-
turnId: 'budget-turn-21'
|
|
2111
|
-
}), /task_terminal/);
|
|
2112
|
-
assert.throws(()=>budgetRestart.issueOperation(budgetTaskId, action('budget-action-21')), /task_terminal/);
|
|
2113
|
-
assert.equal(budgetRestart.continueWithReceipt(twentiethContinuation).phase, 'terminal', 'exact terminal receipt replay is read-only');
|
|
2114
|
-
assert.throws(()=>budgetRestart.emitEvent(budgetTaskId, {
|
|
2115
|
-
kind: 'explanation',
|
|
2116
|
-
explanation: {
|
|
2117
|
-
text: 'must reject',
|
|
2118
|
-
factRefs: []
|
|
2119
|
-
}
|
|
2120
|
-
}), /task_terminal/);
|
|
2121
|
-
assert.throws(()=>budgetRestart.terminalDecisionBatch(budgetTaskId, 'arbitrary-terminal-key'), /task_terminal/);
|
|
2122
|
-
assert.throws(()=>budgetRestart.applyDecisionBatch(budgetTaskId, 'arbitrary-terminal-key-2', [
|
|
2123
|
-
{
|
|
2124
|
-
kind: 'terminal',
|
|
2125
|
-
terminal: {
|
|
2126
|
-
status: 'stopped',
|
|
2127
|
-
summary: 'must reject',
|
|
2128
|
-
factRefs: []
|
|
2129
|
-
}
|
|
2130
|
-
}
|
|
2131
|
-
]), /task_terminal/);
|
|
2132
|
-
assert.throws(()=>budgetRestart.commitDecisionBatch(budgetTaskId, 'arbitrary-terminal-key-3', []), /task_terminal/);
|
|
2133
|
-
assert.equal(budgetLedger.countEvents(), terminalBatchCount, 'post-terminal attempts do not mutate ledger');
|
|
2134
|
-
assert.equal(budgetLedger.db.prepare('SELECT COALESCE(SUM(length(payload)), 0) AS n FROM events').get().n, terminalBatchBytes, 'post-terminal attempts do not mutate ledger bytes');
|
|
2135
|
-
budgetRestart['ledger'].close();
|
|
2136
|
-
budgetLedger.close();
|
|
2137
|
-
rmSync(budgetRoot, {
|
|
2138
|
-
recursive: true,
|
|
2139
|
-
force: true
|
|
2140
|
-
});
|
|
2141
|
-
const bindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-request-binding-'));
|
|
2142
|
-
const bindingRuntime = new BookingCopilotTaskRuntimeV2(ensureLedger(bindingRoot));
|
|
2143
|
-
const bindingTurn1 = {
|
|
2144
|
-
...turn('task-request-binding'),
|
|
2145
|
-
turnId: 'binding-turn-1'
|
|
2146
|
-
};
|
|
2147
|
-
bindingRuntime.startTask(bindingTurn1);
|
|
2148
|
-
bindingRuntime.persistRequestBinding('request-one', bindingTurn1, {
|
|
2149
|
-
requestKey: 'request-one',
|
|
2150
|
-
principal: {
|
|
2151
|
-
subject: 'binding-principal',
|
|
2152
|
-
scope: 'booking:read'
|
|
2153
|
-
}
|
|
2154
|
-
});
|
|
2155
|
-
const firstBatch = bindingRuntime.applyDecisionBatch('task-request-binding', 'request-one', [
|
|
2156
|
-
{
|
|
2157
|
-
kind: 'explanation',
|
|
2158
|
-
explanation: {
|
|
2159
|
-
text: 'first',
|
|
2160
|
-
factRefs: []
|
|
2161
|
-
}
|
|
2162
|
-
}
|
|
2163
|
-
], true);
|
|
2164
|
-
const afterFirstBatch = bindingRuntime['ledger'].countEvents();
|
|
2165
|
-
bindingRuntime.startTask({
|
|
2166
|
-
...bindingTurn1,
|
|
2167
|
-
turnId: 'binding-turn-2'
|
|
2168
|
-
});
|
|
2169
|
-
const bindingTurn2 = {
|
|
2170
|
-
...bindingTurn1,
|
|
2171
|
-
turnId: 'binding-turn-2'
|
|
2172
|
-
};
|
|
2173
|
-
bindingRuntime.persistRequestBinding('request-two', bindingTurn2, {
|
|
2174
|
-
requestKey: 'request-two',
|
|
2175
|
-
principal: {
|
|
2176
|
-
subject: 'binding-principal',
|
|
2177
|
-
scope: 'booking:read'
|
|
2178
|
-
}
|
|
2179
|
-
});
|
|
2180
|
-
assert.deepEqual(bindingRuntime.applyDecisionBatch('task-request-binding', 'request-one', [
|
|
2181
|
-
{
|
|
2182
|
-
kind: 'error',
|
|
2183
|
-
error: {
|
|
2184
|
-
code: 'forged',
|
|
2185
|
-
message: 'forged',
|
|
2186
|
-
retryable: false
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
]), firstBatch, 'R1 replay returns original durable batch after R2');
|
|
2190
|
-
assert.equal(bindingRuntime['ledger'].countEvents(), afterFirstBatch + 2, 'R1 replay and R2 binding do not append a replacement batch');
|
|
2191
|
-
assert.throws(()=>bindingRuntime.assertRequestBinding('request-one', {
|
|
2192
|
-
...bindingTurn1,
|
|
2193
|
-
request: {
|
|
2194
|
-
text: 'changed body'
|
|
2195
|
-
}
|
|
2196
|
-
}, {
|
|
2197
|
-
requestKey: 'request-one',
|
|
2198
|
-
principal: {
|
|
2199
|
-
subject: 'binding-principal',
|
|
2200
|
-
scope: 'booking:read'
|
|
2201
|
-
}
|
|
2202
|
-
}), /request_conflict/);
|
|
2203
|
-
bindingRuntime['ledger'].close();
|
|
2204
|
-
rmSync(bindingRoot, {
|
|
2205
|
-
recursive: true,
|
|
2206
|
-
force: true
|
|
2207
|
-
});
|
|
2208
|
-
const legacyRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-ordinal-'));
|
|
2209
|
-
const legacyLedger = ensureLedger(legacyRoot);
|
|
2210
|
-
const legacyRuntime = new BookingCopilotTaskRuntimeV2(legacyLedger);
|
|
2211
|
-
legacyRuntime.startTask(turn('task-legacy-ordinal'));
|
|
2212
|
-
const legacyOperation = legacyRuntime.issueOperation('task-legacy-ordinal', action('legacy-action'));
|
|
2213
|
-
legacyLedger.db.prepare("UPDATE events SET payload = json_remove(payload, '$.operationCount') WHERE kind = 'booking.copilot.v2.action.issued'").run();
|
|
2214
|
-
const legacyReceipt = legacyRuntime.withReceiptDigest({
|
|
2215
|
-
schemaVersion: 'booking.surface.v2',
|
|
2216
|
-
kind: 'action.receipt',
|
|
2217
|
-
actionId: legacyOperation.action.actionId,
|
|
2218
|
-
contextRef: 'ctx-v2',
|
|
2219
|
-
status: 'applied',
|
|
2220
|
-
revision: 0,
|
|
2221
|
-
observation: {
|
|
2222
|
-
kind: 'search.state',
|
|
2223
|
-
resultCount: 0
|
|
2224
|
-
},
|
|
2225
|
-
resultContract: {
|
|
2226
|
-
outcome: 'complete',
|
|
2227
|
-
hardCriteriaMet: true,
|
|
2228
|
-
factRefs: [],
|
|
2229
|
-
gapCodes: [],
|
|
2230
|
-
blockers: [],
|
|
2231
|
-
relaxationsApplied: []
|
|
2232
|
-
}
|
|
2233
|
-
});
|
|
2234
|
-
legacyRuntime.continueWithReceipt({
|
|
2235
|
-
schemaVersion: 'booking.surface.v2',
|
|
2236
|
-
kind: 'action.receipt.continuation',
|
|
2237
|
-
taskId: 'task-legacy-ordinal',
|
|
2238
|
-
workspace: workspace(0),
|
|
2239
|
-
receipt: legacyReceipt
|
|
2240
|
-
});
|
|
2241
|
-
legacyLedger.db.prepare("UPDATE events SET payload = json_remove(payload, '$.operationCount') WHERE kind = 'booking.copilot.v2.receipt.observed'").run();
|
|
2242
|
-
const legacyRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(legacyRoot));
|
|
2243
|
-
assert.equal(legacyRestart.resumeTask('task-legacy-ordinal')?.operationCount, 1, 'legacy action and receipt rows recover operation ordinal');
|
|
2244
|
-
legacyRestart['ledger'].close();
|
|
2245
|
-
legacyLedger.close();
|
|
2246
|
-
rmSync(legacyRoot, {
|
|
2247
|
-
recursive: true,
|
|
2248
|
-
force: true
|
|
2249
|
-
});
|
|
2250
|
-
const ordinalTamperRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-ordinal-tamper-'));
|
|
2251
|
-
const ordinalTamperLedger = ensureLedger(ordinalTamperRoot);
|
|
2252
|
-
const ordinalTamperRuntime = new BookingCopilotTaskRuntimeV2(ordinalTamperLedger);
|
|
2253
|
-
ordinalTamperRuntime.startTask(turn('task-ordinal-tamper'));
|
|
2254
|
-
ordinalTamperRuntime.issueOperation('task-ordinal-tamper', action('ordinal-action'));
|
|
2255
|
-
ordinalTamperLedger.db.prepare("UPDATE events SET payload = json_set(payload, '$.operationCount', 7) WHERE kind = 'booking.copilot.v2.action.issued'").run();
|
|
2256
|
-
const ordinalRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(ordinalTamperRoot));
|
|
2257
|
-
assert.throws(()=>ordinalRestart.resumeTask('task-ordinal-tamper'), /ledger_corrupt:task-ordinal-tamper:action/, 'inconsistent explicit operation ordinal is rejected');
|
|
2258
|
-
ordinalRestart['ledger'].close();
|
|
2259
|
-
ordinalTamperLedger.close();
|
|
2260
|
-
rmSync(ordinalTamperRoot, {
|
|
2261
|
-
recursive: true,
|
|
2262
|
-
force: true
|
|
2263
|
-
});
|
|
2264
|
-
const rewriteLegacyOfferRows = (ledger, taskId, text, scalarVerifiedOfferRef)=>{
|
|
2265
|
-
const rows = ledger.db.prepare("SELECT seq, kind, payload FROM events WHERE run_id = ? ORDER BY seq").all(taskId);
|
|
2266
|
-
const actionKinds = new Map();
|
|
2267
|
-
const actionWorkspaceDigests = new Map();
|
|
2268
|
-
const workspaceDigestMigration = new Map();
|
|
2269
|
-
let currentWorkspace;
|
|
2270
|
-
for (const row of rows){
|
|
2271
|
-
if (row.kind !== 'booking.copilot.v2.action.issued') continue;
|
|
2272
|
-
const payload = JSON.parse(row.payload);
|
|
2273
|
-
const action = payload.action;
|
|
2274
|
-
if (action && typeof action.actionId === 'string' && typeof action.kind === 'string') actionKinds.set(action.actionId, action.kind);
|
|
2275
|
-
}
|
|
2276
|
-
for (const row of rows){
|
|
2277
|
-
const payload = JSON.parse(row.payload);
|
|
2278
|
-
const receiptActionId = row.kind === 'booking.copilot.v2.receipt.observed' && payload.receipt && typeof payload.receipt.actionId === 'string' ? payload.receipt.actionId : undefined;
|
|
2279
|
-
if (payload.workspace && typeof payload.workspace === 'object') {
|
|
2280
|
-
const versionedWorkspace = structuredClone(payload.workspace);
|
|
2281
|
-
const oldWorkspace = legacyWorkspace(payload.workspace, scalarVerifiedOfferRef);
|
|
2282
|
-
payload.workspace = oldWorkspace;
|
|
2283
|
-
const oldDigest = oldWorkspaceDigest(oldWorkspace);
|
|
2284
|
-
workspaceDigestMigration.set(oldWorkspaceDigest(versionedWorkspace), oldDigest);
|
|
2285
|
-
payload.workspaceDigest = oldDigest;
|
|
2286
|
-
payload.workspaceSemanticDigest = oldWorkspaceSemanticDigest(oldWorkspace);
|
|
2287
|
-
currentWorkspace = oldWorkspace;
|
|
2288
|
-
}
|
|
2289
|
-
if (payload.availability && typeof payload.availability === 'object') {
|
|
2290
|
-
const actionId = payload.action && typeof payload.action === 'object' && typeof payload.action.actionId === 'string' ? payload.action.actionId : undefined;
|
|
2291
|
-
const availabilityValue = structuredClone(payload.availability);
|
|
2292
|
-
const sourceWorkspaceDigest = (()=>{
|
|
2293
|
-
const hotels = availabilityValue.hotels;
|
|
2294
|
-
if (!hotels || typeof hotels !== 'object' || Array.isArray(hotels)) return undefined;
|
|
2295
|
-
for (const hotel of Object.values(hotels)){
|
|
2296
|
-
if (!hotel || typeof hotel !== 'object' || Array.isArray(hotel)) continue;
|
|
2297
|
-
const generation = hotel.currentGeneration;
|
|
2298
|
-
if (!generation || typeof generation !== 'object' || Array.isArray(generation)) continue;
|
|
2299
|
-
const source = generation.source;
|
|
2300
|
-
if (source && typeof source === 'object' && !Array.isArray(source) && source.kind === 'workspace_snapshot' && typeof source.workspaceDigest === 'string') return source.workspaceDigest;
|
|
2301
|
-
}
|
|
2302
|
-
return undefined;
|
|
2303
|
-
})();
|
|
2304
|
-
const sourceDigest = workspaceDigestMigration.get(sourceWorkspaceDigest ?? '') ?? (receiptActionId ? actionWorkspaceDigests.get(receiptActionId) : currentWorkspace ? oldWorkspaceDigest(currentWorkspace) : undefined);
|
|
2305
|
-
const oldAvailability = legacyAvailability(payload.availability, sourceDigest);
|
|
2306
|
-
payload.availability = oldAvailability;
|
|
2307
|
-
payload.availabilityDigest = bookingV2Digest(oldAvailability);
|
|
2308
|
-
if (actionId) actionWorkspaceDigests.set(actionId, currentWorkspace ? oldWorkspaceDigest(currentWorkspace) : '');
|
|
2309
|
-
}
|
|
2310
|
-
if (row.kind === 'booking.copilot.v2.user.turn.observed' && typeof payload.turnId === 'string' && payload.workspace && typeof payload.workspace === 'object') {
|
|
2311
|
-
payload.requestDigest = legacyTurnDigest(taskId, payload.turnId, payload.workspace, text);
|
|
2312
|
-
}
|
|
2313
|
-
if (row.kind === 'booking.copilot.v2.action.issued' && payload.action && typeof payload.action === 'object') {
|
|
2314
|
-
const oldAction = payload.action;
|
|
2315
|
-
const input = oldAction.input;
|
|
2316
|
-
if (input && typeof input === 'object' && !Array.isArray(input) && [
|
|
2317
|
-
'offer.select',
|
|
2318
|
-
'offer.check',
|
|
2319
|
-
'checkout.prepare'
|
|
2320
|
-
].includes(String(oldAction.kind))) {
|
|
2321
|
-
delete input.offerVersionRef;
|
|
2322
|
-
}
|
|
2323
|
-
if (input && typeof input === 'object' && !Array.isArray(input)) oldAction.inputDigest = bookingV2Digest(input);
|
|
2324
|
-
const { actionDigest: _oldActionDigest, ...actionBase } = oldAction;
|
|
2325
|
-
oldAction.actionDigest = bookingV2Digest(actionBase);
|
|
2326
|
-
}
|
|
2327
|
-
if (row.kind === 'booking.copilot.v2.receipt.observed' && payload.receipt && typeof payload.receipt === 'object') {
|
|
2328
|
-
const actionId = typeof payload.receipt.actionId === 'string' ? payload.receipt.actionId : '';
|
|
2329
|
-
const oldReceipt = stripLegacyReceipt(payload.receipt);
|
|
2330
|
-
if (actionKinds.get(actionId) === 'checkout.prepare' && oldReceipt.observation && typeof oldReceipt.observation === 'object') {
|
|
2331
|
-
;
|
|
2332
|
-
oldReceipt.observation.verifiedOfferRef = scalarVerifiedOfferRef;
|
|
2333
|
-
}
|
|
2334
|
-
payload.receipt = oldReceipt;
|
|
2335
|
-
payload.receiptDigest = bookingV2Digest(oldReceipt);
|
|
2336
|
-
}
|
|
2337
|
-
if (row.kind === 'booking.copilot.v2.decision.batch' && Array.isArray(payload.events)) {
|
|
2338
|
-
payload.events = payload.events.map((event)=>{
|
|
2339
|
-
if (!event || typeof event !== 'object' || Array.isArray(event)) return event;
|
|
2340
|
-
const oldEvent = event;
|
|
2341
|
-
if (oldEvent.kind !== 'operation' || !oldEvent.action || typeof oldEvent.action !== 'object' || Array.isArray(oldEvent.action)) return oldEvent;
|
|
2342
|
-
const oldAction = oldEvent.action;
|
|
2343
|
-
const input = oldAction.input;
|
|
2344
|
-
if (input && typeof input === 'object' && !Array.isArray(input) && [
|
|
2345
|
-
'offer.select',
|
|
2346
|
-
'offer.check',
|
|
2347
|
-
'checkout.prepare'
|
|
2348
|
-
].includes(String(oldAction.kind))) delete input.offerVersionRef;
|
|
2349
|
-
return oldEvent;
|
|
2350
|
-
});
|
|
2351
|
-
}
|
|
2352
|
-
ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
|
|
2353
|
-
}
|
|
2354
|
-
};
|
|
2355
|
-
const legacyOfferRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-offer-version-'));
|
|
2356
|
-
const legacyOfferLedger = ensureLedger(legacyOfferRoot);
|
|
2357
|
-
const legacyOfferRuntime = new BookingCopilotTaskRuntimeV2(legacyOfferLedger, {
|
|
2358
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
2359
|
-
});
|
|
2360
|
-
const legacyWorkspace0 = {
|
|
2361
|
-
...workspace(0),
|
|
2362
|
-
visibleHotels: [
|
|
2363
|
-
{
|
|
2364
|
-
hotelRef: 'legacy-hotel',
|
|
2365
|
-
name: 'Legacy Hotel',
|
|
2366
|
-
factRefs: []
|
|
2367
|
-
}
|
|
2368
|
-
],
|
|
2369
|
-
loadedOffers: [
|
|
2370
|
-
loadedOffer('legacy-offer-a', 'legacy-hotel'),
|
|
2371
|
-
loadedOffer('legacy-offer-b', 'legacy-hotel')
|
|
2372
|
-
],
|
|
2373
|
-
selectedOfferRef: 'legacy-offer-a',
|
|
2374
|
-
shortlistedOfferRefs: [
|
|
2375
|
-
'legacy-offer-a',
|
|
2376
|
-
'legacy-offer-b'
|
|
2377
|
-
]
|
|
2378
|
-
};
|
|
2379
|
-
const legacyWorkspace1 = {
|
|
2380
|
-
...legacyWorkspace0,
|
|
2381
|
-
revision: 1,
|
|
2382
|
-
loadedOffers: [
|
|
2383
|
-
{
|
|
2384
|
-
...loadedOffer('legacy-offer-a', 'legacy-hotel', 'legacy-offer-a:v2'),
|
|
2385
|
-
factRefs: [
|
|
2386
|
-
'price'
|
|
2387
|
-
]
|
|
2388
|
-
},
|
|
2389
|
-
loadedOffer('legacy-offer-b', 'legacy-hotel')
|
|
2390
|
-
]
|
|
2391
|
-
};
|
|
2392
|
-
const legacyOfferTask = legacyOfferRuntime.startTask({
|
|
2393
|
-
...turn('task-legacy-offer-version'),
|
|
2394
|
-
workspace: legacyWorkspace0
|
|
2395
|
-
});
|
|
2396
|
-
legacyOfferRuntime.issueOperation(legacyOfferTask.taskId, {
|
|
2397
|
-
...action('legacy-offer-check-v1'),
|
|
2398
|
-
kind: 'offer.check',
|
|
2399
|
-
input: {
|
|
2400
|
-
offerRef: 'legacy-offer-a',
|
|
2401
|
-
offerVersionRef: 'legacy-offer-a:v1'
|
|
2402
|
-
}
|
|
2403
|
-
});
|
|
2404
|
-
legacyOfferRuntime.continueWithReceipt({
|
|
2405
|
-
schemaVersion: 'booking.surface.v2',
|
|
2406
|
-
kind: 'action.receipt.continuation',
|
|
2407
|
-
taskId: legacyOfferTask.taskId,
|
|
2408
|
-
workspace: legacyWorkspace1,
|
|
2409
|
-
receipt: {
|
|
2410
|
-
schemaVersion: 'booking.surface.v2',
|
|
2411
|
-
kind: 'action.receipt',
|
|
2412
|
-
actionId: 'legacy-offer-check-v1',
|
|
2413
|
-
contextRef: 'ctx-v2',
|
|
2414
|
-
status: 'changed',
|
|
2415
|
-
revision: 1,
|
|
2416
|
-
observation: {
|
|
2417
|
-
kind: 'offer.availability',
|
|
2418
|
-
offerRef: 'legacy-offer-a',
|
|
2419
|
-
checkedOfferVersionRef: 'legacy-offer-a:v1',
|
|
2420
|
-
currentOfferVersionRef: 'legacy-offer-a:v2',
|
|
2421
|
-
available: true,
|
|
2422
|
-
changedFactRefs: [
|
|
2423
|
-
'price'
|
|
2424
|
-
],
|
|
2425
|
-
gapCodes: []
|
|
2426
|
-
},
|
|
2427
|
-
resultContract: {
|
|
2428
|
-
outcome: 'partial',
|
|
2429
|
-
hardCriteriaMet: false,
|
|
2430
|
-
factRefs: [],
|
|
2431
|
-
gapCodes: [],
|
|
2432
|
-
blockers: [],
|
|
2433
|
-
relaxationsApplied: []
|
|
2434
|
-
}
|
|
2435
|
-
}
|
|
2436
|
-
});
|
|
2437
|
-
legacyOfferRuntime.startTask({
|
|
2438
|
-
...turn('task-legacy-offer-version', 1),
|
|
2439
|
-
workspace: legacyWorkspace1,
|
|
2440
|
-
turnId: 'task-legacy-offer-version-turn-1'
|
|
2441
|
-
});
|
|
2442
|
-
legacyOfferRuntime.issueOperation(legacyOfferTask.taskId, {
|
|
2443
|
-
...action('legacy-offer-check-v2', 1),
|
|
2444
|
-
kind: 'offer.check',
|
|
2445
|
-
input: {
|
|
2446
|
-
offerRef: 'legacy-offer-a',
|
|
2447
|
-
offerVersionRef: 'legacy-offer-a:v2'
|
|
2448
|
-
}
|
|
2449
|
-
});
|
|
2450
|
-
rewriteLegacyOfferRows(legacyOfferLedger, legacyOfferTask.taskId, 'find hotels in Dubai');
|
|
2451
|
-
const legacyOfferRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(legacyOfferRoot), {
|
|
2452
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
2453
|
-
});
|
|
2454
|
-
const legacyOfferRecovered = legacyOfferRestart.resumeTask(legacyOfferTask.taskId);
|
|
2455
|
-
assert.ok(legacyOfferRecovered && legacyOfferRecovered.pendingAction?.input.offerVersionRef, 'pre-versioned row family migrates to a version-bound pending action');
|
|
2456
|
-
assert.deepEqual(legacyOfferRecovered?.availability.attempts.map((attempt)=>attempt.offerVersionRef), [
|
|
2457
|
-
'legacy-offer-version:' + bookingV2Digest({
|
|
2458
|
-
contextRef: 'ctx-v2',
|
|
2459
|
-
offerRef: 'legacy-offer-a',
|
|
2460
|
-
hotelRef: 'legacy-hotel',
|
|
2461
|
-
evidenceLevel: 'rate_loaded',
|
|
2462
|
-
factRefs: []
|
|
2463
|
-
}).slice(0, 40),
|
|
2464
|
-
'legacy-offer-version:' + bookingV2Digest({
|
|
2465
|
-
contextRef: 'ctx-v2',
|
|
2466
|
-
offerRef: 'legacy-offer-a',
|
|
2467
|
-
hotelRef: 'legacy-hotel',
|
|
2468
|
-
evidenceLevel: 'rate_loaded',
|
|
2469
|
-
factRefs: [
|
|
2470
|
-
'price'
|
|
2471
|
-
]
|
|
2472
|
-
}).slice(0, 40)
|
|
2473
|
-
], 'legacy A attempts retain distinct old v1 and changed v2 identities');
|
|
2474
|
-
legacyOfferRestart['ledger'].close();
|
|
2475
|
-
legacyOfferLedger.close();
|
|
2476
|
-
rmSync(legacyOfferRoot, {
|
|
2477
|
-
recursive: true,
|
|
2478
|
-
force: true
|
|
2479
|
-
});
|
|
2480
|
-
const legacyCheckoutRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-checkout-tail-'));
|
|
2481
|
-
const legacyCheckoutLedger = ensureLedger(legacyCheckoutRoot);
|
|
2482
|
-
const legacyCheckoutRuntime = new BookingCopilotTaskRuntimeV2(legacyCheckoutLedger, {
|
|
2483
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2484
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2485
|
-
});
|
|
2486
|
-
const legacyCheckoutCapability = verifiedCapability('legacy-checkout', 'legacy-checkout:v1');
|
|
2487
|
-
const legacyCheckoutWorkspace = {
|
|
2488
|
-
...workspace(1),
|
|
2489
|
-
visibleHotels: [
|
|
2490
|
-
{
|
|
2491
|
-
hotelRef: 'legacy-checkout-hotel',
|
|
2492
|
-
name: 'Legacy Checkout Hotel',
|
|
2493
|
-
factRefs: []
|
|
2494
|
-
}
|
|
2495
|
-
],
|
|
2496
|
-
loadedOffers: [
|
|
2497
|
-
loadedOffer('legacy-checkout', 'legacy-checkout-hotel')
|
|
2498
|
-
],
|
|
2499
|
-
selectedOfferRef: 'legacy-checkout',
|
|
2500
|
-
verifiedOffer: legacyCheckoutCapability
|
|
2501
|
-
};
|
|
2502
|
-
const legacyCheckoutTask = legacyCheckoutRuntime.startTask({
|
|
2503
|
-
...turn('task-legacy-checkout-tail'),
|
|
2504
|
-
workspace: legacyCheckoutWorkspace
|
|
2505
|
-
});
|
|
2506
|
-
const legacyCheckoutAction = {
|
|
2507
|
-
...action('legacy-checkout-action', 1),
|
|
2508
|
-
kind: 'checkout.prepare',
|
|
2509
|
-
input: {
|
|
2510
|
-
offerRef: 'legacy-checkout',
|
|
2511
|
-
offerVersionRef: 'legacy-checkout:v1',
|
|
2512
|
-
verifiedOfferRef: legacyCheckoutCapability.verifiedOfferRef
|
|
2513
|
-
}
|
|
2514
|
-
};
|
|
2515
|
-
const legacyCheckoutBatch = legacyCheckoutRuntime.applyDecisionBatch(legacyCheckoutTask.taskId, 'legacy-checkout-batch', [
|
|
2516
|
-
{
|
|
2517
|
-
kind: 'operation',
|
|
2518
|
-
action: legacyCheckoutAction
|
|
2519
|
-
},
|
|
2520
|
-
{
|
|
2521
|
-
kind: 'explanation',
|
|
2522
|
-
explanation: {
|
|
2523
|
-
text: 'checkout handoff is awaiting receipt',
|
|
2524
|
-
factRefs: []
|
|
2525
|
-
}
|
|
2526
|
-
}
|
|
2527
|
-
], true);
|
|
2528
|
-
assert.equal(legacyCheckoutBatch.some((event)=>event.kind === 'operation' && event.action.actionId === legacyCheckoutAction.actionId), true, 'normal decision batch includes the checkout operation envelope');
|
|
2529
|
-
const appendLegacyEvent = (eventId, sequence, eventKind)=>{
|
|
2530
|
-
const safeContent = eventKind === 'terminal' ? {
|
|
2531
|
-
status: 'stopped',
|
|
2532
|
-
summary: 'legacy-terminal',
|
|
2533
|
-
factRefs: []
|
|
2534
|
-
} : {
|
|
2535
|
-
code: 'unhandled',
|
|
2536
|
-
message: 'legacy error',
|
|
2537
|
-
retryable: false
|
|
2538
|
-
};
|
|
2539
|
-
legacyCheckoutLedger.insertEvent({
|
|
2540
|
-
actor: 'system:booking-copilot-v2',
|
|
2541
|
-
kind: 'booking.copilot.v2.event.emitted',
|
|
2542
|
-
subjectId: legacyCheckoutTask.taskId,
|
|
2543
|
-
runId: legacyCheckoutTask.taskId,
|
|
2544
|
-
ts: '2026-09-01T10:00:01.000Z',
|
|
2545
|
-
idemKey: `legacy-tail:${eventId}`,
|
|
2546
|
-
payload: {
|
|
2547
|
-
schema: 'booking.copilot.ledger.v2',
|
|
2548
|
-
taskId: legacyCheckoutTask.taskId,
|
|
2549
|
-
contextRef: 'ctx-v2',
|
|
2550
|
-
eventId,
|
|
2551
|
-
sequence,
|
|
2552
|
-
emittedAt: '2026-09-01T10:00:01.000Z',
|
|
2553
|
-
eventKind,
|
|
2554
|
-
contentDigest: bookingV2Digest(safeContent)
|
|
2555
|
-
}
|
|
2556
|
-
});
|
|
2557
|
-
};
|
|
2558
|
-
appendLegacyEvent('legacy-terminal-event', 6, 'terminal');
|
|
2559
|
-
appendLegacyEvent('legacy-error-event', 7, 'error');
|
|
2560
|
-
rewriteLegacyOfferRows(legacyCheckoutLedger, legacyCheckoutTask.taskId, 'find hotels in Dubai', legacyCheckoutCapability.verifiedOfferRef);
|
|
2561
|
-
const legacyCheckoutNextWorkspace = {
|
|
2562
|
-
...legacyCheckoutWorkspace,
|
|
2563
|
-
revision: 2,
|
|
2564
|
-
verifiedOffer: undefined
|
|
2565
|
-
};
|
|
2566
|
-
legacyCheckoutRuntime.appendTurn(legacyCheckoutTask.taskId, 'ctx-v2', bookingV2Digest({
|
|
2567
|
-
schemaVersion: 'booking.surface.v2',
|
|
2568
|
-
kind: 'user.turn',
|
|
2569
|
-
taskId: legacyCheckoutTask.taskId,
|
|
2570
|
-
turnId: 'legacy-checkout-next-turn',
|
|
2571
|
-
workspace: legacyCheckoutNextWorkspace,
|
|
2572
|
-
request: {
|
|
2573
|
-
text: 'check the replacement room'
|
|
2574
|
-
}
|
|
2575
|
-
}), oldWorkspaceDigest(legacyCheckoutNextWorkspace), oldWorkspaceSemanticDigest(legacyCheckoutNextWorkspace), 2, 'legacy-checkout-next-turn', legacyCheckoutNextWorkspace);
|
|
2576
|
-
const legacyCheckoutRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(legacyCheckoutRoot), {
|
|
2577
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2578
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2579
|
-
});
|
|
2580
|
-
const legacyCheckoutRecovery = legacyCheckoutRestart.resumeTask(legacyCheckoutTask.taskId);
|
|
2581
|
-
assert.equal(legacyCheckoutRecovery?.workspaceSnapshot?.verifiedOffer, undefined, 'legacy scalar verifiedOfferRef never becomes a live capability');
|
|
2582
|
-
assert.equal(legacyCheckoutRecovery?.phase, 'planning', 'unsafe legacy checkout tail suppresses old terminal/error events and replans');
|
|
2583
|
-
assert.equal(legacyCheckoutRecovery?.operationCount, 1, 'legacy checkout action ordinal remains consumed after tail skip');
|
|
2584
|
-
assert.ok((legacyCheckoutRecovery?.lastSequence ?? 0) >= 7, 'legacy terminal/error sequence remains consumed for append-only continuation');
|
|
2585
|
-
legacyCheckoutRestart.issueOperation(legacyCheckoutTask.taskId, {
|
|
2586
|
-
...action('legacy-checkout-recheck', 2),
|
|
2587
|
-
kind: 'offer.check',
|
|
2588
|
-
input: {
|
|
2589
|
-
offerRef: 'legacy-checkout',
|
|
2590
|
-
offerVersionRef: 'legacy-checkout:v1'
|
|
2591
|
-
}
|
|
2592
|
-
});
|
|
2593
|
-
const legacyCheckoutAfterAppend = legacyCheckoutRestart.resumeTask(legacyCheckoutTask.taskId);
|
|
2594
|
-
assert.equal(legacyCheckoutAfterAppend?.phase, 'waiting_receipt', 'new typed offer.check remains reachable after unsafe legacy tail');
|
|
2595
|
-
assert.equal(legacyCheckoutAfterAppend?.operationCount, 2);
|
|
2596
|
-
assert.equal(legacyCheckoutAfterAppend?.revision, 2);
|
|
2597
|
-
const legacyCheckoutNewEvent = legacyCheckoutRestart.emitEvent(legacyCheckoutTask.taskId, {
|
|
2598
|
-
kind: 'explanation',
|
|
2599
|
-
explanation: {
|
|
2600
|
-
text: 'new anchor event',
|
|
2601
|
-
factRefs: []
|
|
2602
|
-
}
|
|
2603
|
-
});
|
|
2604
|
-
assert.equal(legacyCheckoutNewEvent.sequence, (legacyCheckoutAfterAppend?.lastSequence ?? 0) + 1, 'new post-reanchor event receives the next unique sequence');
|
|
2605
|
-
legacyCheckoutRestart['ledger'].close();
|
|
2606
|
-
legacyCheckoutLedger.close();
|
|
2607
|
-
rmSync(legacyCheckoutRoot, {
|
|
2608
|
-
recursive: true,
|
|
2609
|
-
force: true
|
|
2610
|
-
});
|
|
2611
|
-
const legacyBatchVariantFixture = (variant)=>{
|
|
2612
|
-
const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-batch-${variant}-`));
|
|
2613
|
-
const ledger = ensureLedger(root);
|
|
2614
|
-
const writer = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
2615
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2616
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2617
|
-
});
|
|
2618
|
-
const taskId = `task-legacy-batch-${variant}`;
|
|
2619
|
-
const capability = verifiedCapability(`batch-${variant}-offer`);
|
|
2620
|
-
const batchWorkspace = {
|
|
2621
|
-
...workspace(0),
|
|
2622
|
-
visibleHotels: [
|
|
2623
|
-
{
|
|
2624
|
-
hotelRef: `batch-${variant}-hotel`,
|
|
2625
|
-
name: 'Batch Hotel',
|
|
2626
|
-
factRefs: []
|
|
2627
|
-
}
|
|
2628
|
-
],
|
|
2629
|
-
loadedOffers: [
|
|
2630
|
-
loadedOffer(`batch-${variant}-offer`, `batch-${variant}-hotel`)
|
|
2631
|
-
],
|
|
2632
|
-
selectedOfferRef: `batch-${variant}-offer`,
|
|
2633
|
-
verifiedOffer: capability
|
|
2634
|
-
};
|
|
2635
|
-
writer.startTask({
|
|
2636
|
-
...turn(taskId),
|
|
2637
|
-
workspace: batchWorkspace
|
|
2638
|
-
});
|
|
2639
|
-
const batchAction = {
|
|
2640
|
-
...action(`batch-${variant}-checkout`),
|
|
2641
|
-
kind: 'checkout.prepare',
|
|
2642
|
-
input: {
|
|
2643
|
-
offerRef: `batch-${variant}-offer`,
|
|
2644
|
-
offerVersionRef: `batch-${variant}-offer:v1`,
|
|
2645
|
-
verifiedOfferRef: capability.verifiedOfferRef
|
|
2646
|
-
}
|
|
2647
|
-
};
|
|
2648
|
-
writer.applyDecisionBatch(taskId, `batch-envelope-${variant}`, [
|
|
2649
|
-
{
|
|
2650
|
-
kind: 'operation',
|
|
2651
|
-
action: batchAction
|
|
2652
|
-
},
|
|
2653
|
-
{
|
|
2654
|
-
kind: 'explanation',
|
|
2655
|
-
explanation: {
|
|
2656
|
-
text: 'batch envelope',
|
|
2657
|
-
factRefs: []
|
|
2658
|
-
}
|
|
2659
|
-
}
|
|
2660
|
-
], true);
|
|
2661
|
-
rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai', capability.verifiedOfferRef);
|
|
2662
|
-
const row = ledger.db.prepare("SELECT seq, payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.v2.decision.batch'").get(taskId);
|
|
2663
|
-
const payload = JSON.parse(row.payload);
|
|
2664
|
-
if (variant === 'tampered') {
|
|
2665
|
-
const explanation = payload.events.find((event)=>event.kind === 'explanation');
|
|
2666
|
-
if (explanation && explanation.explanation && typeof explanation.explanation === 'object') explanation.explanation.text = 'edited envelope';
|
|
2667
|
-
} else if (variant === 'missing') {
|
|
2668
|
-
const operationIndex = payload.events.findIndex((event)=>event.kind === 'operation');
|
|
2669
|
-
assert.ok(operationIndex >= 0, 'missing batch fixture has a retained operation identity to remove');
|
|
2670
|
-
payload.events.splice(operationIndex, 1);
|
|
2671
|
-
} else if (variant === 'unknown') {
|
|
2672
|
-
const first = payload.events[0];
|
|
2673
|
-
if (first) first.eventId = 'unknown-envelope-event';
|
|
2674
|
-
} else {
|
|
2675
|
-
const first = payload.events[0];
|
|
2676
|
-
if (first) payload.events.push(structuredClone(first));
|
|
2677
|
-
}
|
|
2678
|
-
ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(payload), row.seq);
|
|
2679
|
-
ledger.close();
|
|
2680
|
-
return {
|
|
2681
|
-
root,
|
|
2682
|
-
taskId
|
|
2683
|
-
};
|
|
2684
|
-
};
|
|
2685
|
-
for (const variant of [
|
|
2686
|
-
'tampered',
|
|
2687
|
-
'missing',
|
|
2688
|
-
'unknown',
|
|
2689
|
-
'duplicate'
|
|
2690
|
-
]){
|
|
2691
|
-
const fixtureState = legacyBatchVariantFixture(variant);
|
|
2692
|
-
const replayLedger = ensureLedger(fixtureState.root);
|
|
2693
|
-
const replay = new BookingCopilotTaskRuntimeV2(replayLedger, {
|
|
2694
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2695
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2696
|
-
});
|
|
2697
|
-
assert.throws(()=>replay.resumeTask(fixtureState.taskId), new RegExp(`ledger_corrupt:${fixtureState.taskId}:decision_batch`), `legacy duplicate batch ${variant} mismatch fails closed`);
|
|
2698
|
-
replayLedger.close();
|
|
2699
|
-
rmSync(fixtureState.root, {
|
|
2700
|
-
recursive: true,
|
|
2701
|
-
force: true
|
|
2702
|
-
});
|
|
2703
|
-
}
|
|
2704
|
-
const legacyTwoBatchFixture = (crossBatchGraft)=>{
|
|
2705
|
-
const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-two-batch-${crossBatchGraft ? 'graft' : 'clean'}-`));
|
|
2706
|
-
const ledger = ensureLedger(root);
|
|
2707
|
-
const writer = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
2708
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2709
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2710
|
-
});
|
|
2711
|
-
const taskId = `task-legacy-two-batch-${crossBatchGraft ? 'graft' : 'clean'}`;
|
|
2712
|
-
writer.startTask(turn(taskId));
|
|
2713
|
-
const standaloneStatus = writer.emitEvent(taskId, {
|
|
2714
|
-
kind: 'status',
|
|
2715
|
-
status: 'submitted'
|
|
2716
|
-
});
|
|
2717
|
-
const standaloneAction = writer.issueOperation(taskId, action(`${taskId}-standalone-action`));
|
|
2718
|
-
const standaloneReceipt = writer.withReceiptDigest({
|
|
2719
|
-
schemaVersion: 'booking.surface.v2',
|
|
2720
|
-
kind: 'action.receipt',
|
|
2721
|
-
actionId: standaloneAction.action.actionId,
|
|
2722
|
-
contextRef: 'ctx-v2',
|
|
2723
|
-
status: 'applied',
|
|
2724
|
-
revision: 1,
|
|
2725
|
-
observation: {
|
|
2726
|
-
kind: 'search.state',
|
|
2727
|
-
resultCount: 0
|
|
2728
|
-
},
|
|
2729
|
-
resultContract: {
|
|
2730
|
-
outcome: 'complete',
|
|
2731
|
-
hardCriteriaMet: true,
|
|
2732
|
-
factRefs: [],
|
|
2733
|
-
gapCodes: [],
|
|
2734
|
-
blockers: [],
|
|
2735
|
-
relaxationsApplied: []
|
|
2736
|
-
}
|
|
2737
|
-
});
|
|
2738
|
-
writer.continueWithReceipt({
|
|
2739
|
-
schemaVersion: 'booking.surface.v2',
|
|
2740
|
-
kind: 'action.receipt.continuation',
|
|
2741
|
-
taskId,
|
|
2742
|
-
workspace: workspace(1),
|
|
2743
|
-
receipt: standaloneReceipt
|
|
2744
|
-
});
|
|
2745
|
-
const capability = verifiedCapability(`${taskId}-offer`);
|
|
2746
|
-
const batchWorkspace = {
|
|
2747
|
-
...workspace(1),
|
|
2748
|
-
visibleHotels: [
|
|
2749
|
-
{
|
|
2750
|
-
hotelRef: `${taskId}-hotel`,
|
|
2751
|
-
name: 'Two Batch Hotel',
|
|
2752
|
-
factRefs: []
|
|
2753
|
-
}
|
|
2754
|
-
],
|
|
2755
|
-
loadedOffers: [
|
|
2756
|
-
loadedOffer(`${taskId}-offer`, `${taskId}-hotel`)
|
|
2757
|
-
],
|
|
2758
|
-
selectedOfferRef: `${taskId}-offer`,
|
|
2759
|
-
verifiedOffer: capability
|
|
2760
|
-
};
|
|
2761
|
-
writer.startTask({
|
|
2762
|
-
...turn(taskId, 1),
|
|
2763
|
-
turnId: `${taskId}-anchor-turn`,
|
|
2764
|
-
workspace: batchWorkspace
|
|
2765
|
-
});
|
|
2766
|
-
const normalBatch = writer.applyDecisionBatch(taskId, `${taskId}-normal-batch`, [
|
|
2767
|
-
{
|
|
2768
|
-
kind: 'explanation',
|
|
2769
|
-
explanation: {
|
|
2770
|
-
text: 'normal completed batch',
|
|
2771
|
-
factRefs: []
|
|
2772
|
-
}
|
|
2773
|
-
}
|
|
2774
|
-
], true);
|
|
2775
|
-
assert.ok(normalBatch.some((event)=>event.kind === 'explanation'), 'normal batch persists its own explanation');
|
|
2776
|
-
const unsafeAction = {
|
|
2777
|
-
...action(`${taskId}-unsafe-checkout`, 1),
|
|
2778
|
-
kind: 'checkout.prepare',
|
|
2779
|
-
input: {
|
|
2780
|
-
offerRef: `${taskId}-offer`,
|
|
2781
|
-
offerVersionRef: `${taskId}-offer:v1`,
|
|
2782
|
-
verifiedOfferRef: capability.verifiedOfferRef
|
|
2783
|
-
}
|
|
2784
|
-
};
|
|
2785
|
-
writer.applyDecisionBatch(taskId, `${taskId}-unsafe-batch`, [
|
|
2786
|
-
{
|
|
2787
|
-
kind: 'operation',
|
|
2788
|
-
action: unsafeAction
|
|
2789
|
-
},
|
|
2790
|
-
{
|
|
2791
|
-
kind: 'explanation',
|
|
2792
|
-
explanation: {
|
|
2793
|
-
text: 'unsafe legacy batch',
|
|
2794
|
-
factRefs: []
|
|
2795
|
-
}
|
|
2796
|
-
}
|
|
2797
|
-
], true);
|
|
2798
|
-
rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai', capability.verifiedOfferRef);
|
|
2799
|
-
if (crossBatchGraft) {
|
|
2800
|
-
const rows = ledger.db.prepare("SELECT seq, payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.v2.decision.batch' ORDER BY seq").all(taskId);
|
|
2801
|
-
assert.equal(rows.length, 2, 'two-batch fixture retains both decision envelopes');
|
|
2802
|
-
const firstPayload = JSON.parse(rows[0].payload);
|
|
2803
|
-
const secondPayload = JSON.parse(rows[1].payload);
|
|
2804
|
-
const graft = firstPayload.events.find((event)=>event.kind === 'explanation');
|
|
2805
|
-
assert.ok(graft, 'first batch has an event identity available for graft detection');
|
|
2806
|
-
const graftIndex = secondPayload.events.findIndex((event)=>event.kind === 'explanation');
|
|
2807
|
-
assert.ok(graftIndex >= 0, 'second batch has a retained event slot for graft detection');
|
|
2808
|
-
secondPayload.events[graftIndex] = structuredClone(graft);
|
|
2809
|
-
ledger.db.prepare('UPDATE events SET payload = ? WHERE seq = ?').run(JSON.stringify(secondPayload), rows[1].seq);
|
|
2810
|
-
}
|
|
2811
|
-
assert.equal(standaloneStatus.sequence < standaloneAction.sequence, true, 'standalone EVENT precedes standalone ACTION before batched work');
|
|
2812
|
-
ledger.close();
|
|
2813
|
-
return {
|
|
2814
|
-
root,
|
|
2815
|
-
taskId
|
|
2816
|
-
};
|
|
2817
|
-
};
|
|
2818
|
-
const cleanTwoBatch = legacyTwoBatchFixture(false);
|
|
2819
|
-
const cleanTwoBatchReplayLedger = ensureLedger(cleanTwoBatch.root);
|
|
2820
|
-
const cleanTwoBatchReplay = new BookingCopilotTaskRuntimeV2(cleanTwoBatchReplayLedger, {
|
|
2821
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2822
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2823
|
-
});
|
|
2824
|
-
const cleanTwoBatchState = cleanTwoBatchReplay.resumeTask(cleanTwoBatch.taskId);
|
|
2825
|
-
assert.equal(cleanTwoBatchState?.phase, 'planning', 'normal batch replays before unsafe legacy batch enters replan');
|
|
2826
|
-
assert.equal(cleanTwoBatchState?.operationCount, 2, 'standalone action and unsafe batch action ordinals remain consumed');
|
|
2827
|
-
cleanTwoBatchReplayLedger.close();
|
|
2828
|
-
rmSync(cleanTwoBatch.root, {
|
|
2829
|
-
recursive: true,
|
|
2830
|
-
force: true
|
|
2831
|
-
});
|
|
2832
|
-
const graftedTwoBatch = legacyTwoBatchFixture(true);
|
|
2833
|
-
const graftedTwoBatchReplayLedger = ensureLedger(graftedTwoBatch.root);
|
|
2834
|
-
const graftedTwoBatchReplay = new BookingCopilotTaskRuntimeV2(graftedTwoBatchReplayLedger, {
|
|
2835
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2836
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2837
|
-
});
|
|
2838
|
-
assert.throws(()=>graftedTwoBatchReplay.resumeTask(graftedTwoBatch.taskId), new RegExp(`ledger_corrupt:${graftedTwoBatch.taskId}:decision_batch`), 'cross-batch event graft fails closed within the second envelope');
|
|
2839
|
-
graftedTwoBatchReplayLedger.close();
|
|
2840
|
-
rmSync(graftedTwoBatch.root, {
|
|
2841
|
-
recursive: true,
|
|
2842
|
-
force: true
|
|
2843
|
-
});
|
|
2844
|
-
for (const kind of [
|
|
2845
|
-
'offer.select',
|
|
2846
|
-
'offer.check'
|
|
2847
|
-
]){
|
|
2848
|
-
const root = mkdtempSync(join(tmpdir(), `gotry-booking-v2-legacy-safe-batch-${kind.replace('.', '-')}-`));
|
|
2849
|
-
const ledger = ensureLedger(root);
|
|
2850
|
-
const writer = new BookingCopilotTaskRuntimeV2(ledger, {
|
|
2851
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2852
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2853
|
-
});
|
|
2854
|
-
const taskId = `task-legacy-safe-batch-${kind.replace('.', '-')}`;
|
|
2855
|
-
const offerRef = `${taskId}-offer`;
|
|
2856
|
-
const hotelRef = `${taskId}-hotel`;
|
|
2857
|
-
const safeWorkspace = {
|
|
2858
|
-
...workspace(0),
|
|
2859
|
-
visibleHotels: [
|
|
2860
|
-
{
|
|
2861
|
-
hotelRef,
|
|
2862
|
-
name: 'Safe Batch Hotel',
|
|
2863
|
-
factRefs: []
|
|
2864
|
-
}
|
|
2865
|
-
],
|
|
2866
|
-
loadedOffers: [
|
|
2867
|
-
loadedOffer(offerRef, hotelRef)
|
|
2868
|
-
]
|
|
2869
|
-
};
|
|
2870
|
-
writer.startTask({
|
|
2871
|
-
...turn(taskId),
|
|
2872
|
-
workspace: safeWorkspace
|
|
2873
|
-
});
|
|
2874
|
-
const safeAction = {
|
|
2875
|
-
...action(`${taskId}-action`),
|
|
2876
|
-
kind,
|
|
2877
|
-
input: {
|
|
2878
|
-
offerRef,
|
|
2879
|
-
offerVersionRef: `${offerRef}:v1`
|
|
2880
|
-
}
|
|
2881
|
-
};
|
|
2882
|
-
const requestKey = `${taskId}-request`;
|
|
2883
|
-
writer.applyDecisionBatch(taskId, requestKey, [
|
|
2884
|
-
{
|
|
2885
|
-
kind: 'operation',
|
|
2886
|
-
action: safeAction
|
|
2887
|
-
}
|
|
2888
|
-
], true);
|
|
2889
|
-
rewriteLegacyOfferRows(ledger, taskId, 'find hotels in Dubai');
|
|
2890
|
-
ledger.close();
|
|
2891
|
-
const restartLedger = ensureLedger(root);
|
|
2892
|
-
const restart = new BookingCopilotTaskRuntimeV2(restartLedger, {
|
|
2893
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
2894
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
2895
|
-
});
|
|
2896
|
-
const recovered = restart.resumeTask(taskId);
|
|
2897
|
-
const recoveredOperation = recovered?.pendingAction;
|
|
2898
|
-
const expectedVersion = `legacy-offer-version:${bookingV2Digest({
|
|
2899
|
-
contextRef: 'ctx-v2',
|
|
2900
|
-
offerRef,
|
|
2901
|
-
hotelRef,
|
|
2902
|
-
evidenceLevel: 'rate_loaded',
|
|
2903
|
-
factRefs: []
|
|
2904
|
-
}).slice(0, 40)}`;
|
|
2905
|
-
const offerVersionFromAction = (candidate)=>{
|
|
2906
|
-
const input = candidate?.input;
|
|
2907
|
-
return input && typeof input === 'object' && !Array.isArray(input) && 'offerVersionRef' in input && typeof input.offerVersionRef === 'string' ? input.offerVersionRef : undefined;
|
|
2908
|
-
};
|
|
2909
|
-
assert.equal(recoveredOperation?.kind, kind, `legacy ${kind} batch restart retains operation kind`);
|
|
2910
|
-
assert.equal(offerVersionFromAction(recoveredOperation), expectedVersion, `legacy ${kind} batch restart binds deterministic synthetic version`);
|
|
2911
|
-
const replayBatch = restart.readDecisionBatch(taskId, requestKey);
|
|
2912
|
-
assert.ok(replayBatch, `legacy ${kind} batch is readable after restart`);
|
|
2913
|
-
const replayOperation = replayBatch?.find((event)=>event.kind === 'operation');
|
|
2914
|
-
assert.ok(replayOperation && validateBookingSurfaceV2(replayOperation).ok, `legacy ${kind} batch operation is schema-valid after replay normalization`);
|
|
2915
|
-
assert.equal(replayOperation?.kind === 'operation' ? offerVersionFromAction(replayOperation.action) : undefined, expectedVersion, `legacy ${kind} batch replay operation carries synthetic version`);
|
|
2916
|
-
restartLedger.close();
|
|
2917
|
-
rmSync(root, {
|
|
2918
|
-
recursive: true,
|
|
2919
|
-
force: true
|
|
2920
|
-
});
|
|
2921
|
-
}
|
|
2922
|
-
const legacyBlockerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-blocker-replan-'));
|
|
2923
|
-
const legacyBlockerLedger = ensureLedger(legacyBlockerRoot);
|
|
2924
|
-
const legacyBlockerRuntime = new BookingCopilotTaskRuntimeV2(legacyBlockerLedger, {
|
|
2925
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
2926
|
-
});
|
|
2927
|
-
const blockerWorkspace0 = {
|
|
2928
|
-
...workspace(0),
|
|
2929
|
-
visibleHotels: [
|
|
2930
|
-
{
|
|
2931
|
-
hotelRef: 'blocker-hotel',
|
|
2932
|
-
name: 'Blocker Hotel',
|
|
2933
|
-
factRefs: []
|
|
2934
|
-
}
|
|
2935
|
-
],
|
|
2936
|
-
loadedOffers: [
|
|
2937
|
-
loadedOffer('blocker-offer', 'blocker-hotel')
|
|
2938
|
-
]
|
|
2939
|
-
};
|
|
2940
|
-
const blockerTask = legacyBlockerRuntime.startTask({
|
|
2941
|
-
...turn('task-legacy-blocker-lineage'),
|
|
2942
|
-
workspace: blockerWorkspace0
|
|
2943
|
-
});
|
|
2944
|
-
legacyBlockerRuntime.issueOperation(blockerTask.taskId, {
|
|
2945
|
-
...action('legacy-blocker-check'),
|
|
2946
|
-
kind: 'offer.check',
|
|
2947
|
-
input: {
|
|
2948
|
-
offerRef: 'blocker-offer',
|
|
2949
|
-
offerVersionRef: 'blocker-offer:v1'
|
|
2950
|
-
}
|
|
2951
|
-
});
|
|
2952
|
-
const legacyLineageReceipt = legacyBlockerRuntime.withReceiptDigest({
|
|
2953
|
-
schemaVersion: 'booking.surface.v2',
|
|
2954
|
-
kind: 'action.receipt',
|
|
2955
|
-
actionId: 'legacy-blocker-check',
|
|
2956
|
-
contextRef: 'ctx-v2',
|
|
2957
|
-
status: 'needs_input',
|
|
2958
|
-
revision: 1,
|
|
2959
|
-
observation: {
|
|
2960
|
-
kind: 'offer.availability',
|
|
2961
|
-
offerRef: 'blocker-offer',
|
|
2962
|
-
checkedOfferVersionRef: 'blocker-offer:v1',
|
|
2963
|
-
available: false,
|
|
2964
|
-
changedFactRefs: [],
|
|
2965
|
-
gapCodes: []
|
|
2966
|
-
},
|
|
2967
|
-
resultContract: {
|
|
2968
|
-
outcome: 'partial',
|
|
2969
|
-
hardCriteriaMet: false,
|
|
2970
|
-
factRefs: [
|
|
2971
|
-
'fact-free-cancellation'
|
|
2972
|
-
],
|
|
2973
|
-
gapCodes: [
|
|
2974
|
-
'criterion_must_not_met'
|
|
2975
|
-
],
|
|
2976
|
-
blockers: [
|
|
2977
|
-
{
|
|
2978
|
-
blockerId: 'legacy-blocker',
|
|
2979
|
-
sourceActionId: 'legacy-blocker-check',
|
|
2980
|
-
sourceReceiptDigest: '',
|
|
2981
|
-
scope: 'availability',
|
|
2982
|
-
code: 'criterion_must_not_met',
|
|
2983
|
-
criterionPath: 'offers.freeCancellation',
|
|
2984
|
-
strength: 'must',
|
|
2985
|
-
valueDigest: 'c'.repeat(64),
|
|
2986
|
-
evidence: {
|
|
2987
|
-
factRefs: [
|
|
2988
|
-
'fact-free-cancellation'
|
|
2989
|
-
],
|
|
2990
|
-
gapCodes: [
|
|
2991
|
-
'criterion_must_not_met'
|
|
2992
|
-
]
|
|
2993
|
-
}
|
|
2994
|
-
}
|
|
2995
|
-
],
|
|
2996
|
-
relaxationsApplied: []
|
|
2997
|
-
}
|
|
2998
|
-
});
|
|
2999
|
-
legacyBlockerRuntime.continueWithReceipt({
|
|
3000
|
-
schemaVersion: 'booking.surface.v2',
|
|
3001
|
-
kind: 'action.receipt.continuation',
|
|
3002
|
-
taskId: blockerTask.taskId,
|
|
3003
|
-
workspace: {
|
|
3004
|
-
...blockerWorkspace0,
|
|
3005
|
-
revision: 1
|
|
3006
|
-
},
|
|
3007
|
-
receipt: legacyLineageReceipt
|
|
3008
|
-
});
|
|
3009
|
-
rewriteLegacyOfferRows(legacyBlockerLedger, blockerTask.taskId, 'find hotels in Dubai');
|
|
3010
|
-
const legacyBlockerRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(legacyBlockerRoot), {
|
|
3011
|
-
contextRefFactory: ()=>'ctx-v2'
|
|
3012
|
-
});
|
|
3013
|
-
const blockerRecovery = legacyBlockerRestart.resumeTask(blockerTask.taskId);
|
|
3014
|
-
assert.equal(blockerRecovery?.phase, 'planning', 'normalized legacy blocker receipt fail-closes to replan');
|
|
3015
|
-
assert.equal(blockerRecovery?.replayUpgradeRequired, true, 'legacy blocker lineage records an explicit replay upgrade gap');
|
|
3016
|
-
assert.equal(blockerRecovery?.pendingAction, undefined);
|
|
3017
|
-
assert.throws(()=>legacyBlockerRestart.issueOperation(blockerTask.taskId, action('blocked-replan-action', 1)), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks direct operation before a new anchor turn');
|
|
3018
|
-
assert.throws(()=>legacyBlockerRestart.applyDecisionBatch(blockerTask.taskId, 'blocked-replan-batch', [
|
|
3019
|
-
{
|
|
3020
|
-
kind: 'operation',
|
|
3021
|
-
action: action('blocked-replan-decision', 1)
|
|
3022
|
-
}
|
|
3023
|
-
]), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks decision batches before a new anchor turn');
|
|
3024
|
-
assert.throws(()=>legacyBlockerRestart.emitEvent(blockerTask.taskId, {
|
|
3025
|
-
kind: 'explanation',
|
|
3026
|
-
explanation: {
|
|
3027
|
-
text: 'must wait for a new anchor turn',
|
|
3028
|
-
factRefs: []
|
|
3029
|
-
}
|
|
3030
|
-
}), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks standalone event mutation');
|
|
3031
|
-
const blockedBindingTurn = {
|
|
3032
|
-
...turn(blockerTask.taskId, 1),
|
|
3033
|
-
turnId: 'blocked-replan-binding-turn'
|
|
3034
|
-
};
|
|
3035
|
-
assert.throws(()=>legacyBlockerRestart.persistRequestBinding('blocked-replan-request', blockedBindingTurn, {
|
|
3036
|
-
requestKey: 'blocked-replan-request',
|
|
3037
|
-
principal: {
|
|
3038
|
-
subject: 'blocked-replan-principal',
|
|
3039
|
-
scope: 'booking:read'
|
|
3040
|
-
}
|
|
3041
|
-
}), /replay_upgrade|reanchor|replan|turn_required/, 'replay upgrade gap blocks request binding mutation');
|
|
3042
|
-
assert.throws(()=>legacyBlockerRestart.terminalDecisionBatch(blockerTask.taskId, 'blocked-replan-terminal-batch'), /replay_upgrade|reanchor|replan|turn_required|task_not_terminal/, 'replay upgrade gap blocks terminal decision mutation');
|
|
3043
|
-
const reanchorWorkspace = blockerRecovery.workspaceSnapshot;
|
|
3044
|
-
const reanchor = legacyBlockerRestart.startTask({
|
|
3045
|
-
...turn(blockerTask.taskId, reanchorWorkspace.revision),
|
|
3046
|
-
turnId: 'legacy-blocker-reanchor-turn',
|
|
3047
|
-
workspace: reanchorWorkspace,
|
|
3048
|
-
request: {
|
|
3049
|
-
text: 're-anchor the upgraded workspace'
|
|
3050
|
-
}
|
|
3051
|
-
});
|
|
3052
|
-
assert.equal(reanchor.replayUpgradeRequired, undefined, 'validated new TURN clears replay upgrade gap');
|
|
3053
|
-
legacyBlockerRestart['ledger'].close();
|
|
3054
|
-
legacyBlockerLedger.close();
|
|
3055
|
-
rmSync(legacyBlockerRoot, {
|
|
3056
|
-
recursive: true,
|
|
3057
|
-
force: true
|
|
3058
|
-
});
|
|
3059
|
-
const skippedActionRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-legacy-skipped-action-tamper-'));
|
|
3060
|
-
const skippedActionLedger = ensureLedger(skippedActionRoot);
|
|
3061
|
-
const skippedActionWriter = new BookingCopilotTaskRuntimeV2(skippedActionLedger, {
|
|
3062
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
3063
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
3064
|
-
});
|
|
3065
|
-
const skippedWorkspace = {
|
|
3066
|
-
...workspace(0),
|
|
3067
|
-
visibleHotels: [
|
|
3068
|
-
{
|
|
3069
|
-
hotelRef: 'skipped-hotel',
|
|
3070
|
-
name: 'Skipped Hotel',
|
|
3071
|
-
factRefs: []
|
|
3072
|
-
}
|
|
3073
|
-
],
|
|
3074
|
-
loadedOffers: [
|
|
3075
|
-
loadedOffer('skipped-offer', 'skipped-hotel')
|
|
3076
|
-
],
|
|
3077
|
-
selectedOfferRef: 'skipped-offer',
|
|
3078
|
-
verifiedOffer: verifiedCapability('skipped-offer')
|
|
3079
|
-
};
|
|
3080
|
-
const skippedTask = skippedActionWriter.startTask({
|
|
3081
|
-
...turn('task-legacy-skipped-action'),
|
|
3082
|
-
workspace: skippedWorkspace
|
|
3083
|
-
});
|
|
3084
|
-
skippedActionWriter.issueOperation(skippedTask.taskId, {
|
|
3085
|
-
...action('unsafe-checkout-tail'),
|
|
3086
|
-
kind: 'checkout.prepare',
|
|
3087
|
-
input: {
|
|
3088
|
-
offerRef: 'skipped-offer',
|
|
3089
|
-
offerVersionRef: 'skipped-offer:v1',
|
|
3090
|
-
verifiedOfferRef: 'verified-skipped-offer'
|
|
3091
|
-
}
|
|
3092
|
-
});
|
|
3093
|
-
rewriteLegacyOfferRows(skippedActionLedger, skippedTask.taskId, 'find hotels in Dubai', 'verified-skipped-offer');
|
|
3094
|
-
skippedActionLedger.insertEvent({
|
|
3095
|
-
actor: 'system:booking-copilot-v2',
|
|
3096
|
-
kind: 'booking.copilot.v2.event.emitted',
|
|
3097
|
-
subjectId: skippedTask.taskId,
|
|
3098
|
-
runId: skippedTask.taskId,
|
|
3099
|
-
ts: '2026-09-01T10:00:01.000Z',
|
|
3100
|
-
idemKey: 'skipped-action-tail-event',
|
|
3101
|
-
payload: {
|
|
3102
|
-
schema: 'booking.copilot.ledger.v2',
|
|
3103
|
-
taskId: skippedTask.taskId,
|
|
3104
|
-
contextRef: 'ctx-v2',
|
|
3105
|
-
eventId: 'skipped-action-tail-event',
|
|
3106
|
-
sequence: 2,
|
|
3107
|
-
emittedAt: '2026-09-01T10:00:01.000Z',
|
|
3108
|
-
eventKind: 'error',
|
|
3109
|
-
contentDigest: bookingV2Digest({
|
|
3110
|
-
code: 'unhandled',
|
|
3111
|
-
message: 'legacy error',
|
|
3112
|
-
retryable: false
|
|
3113
|
-
})
|
|
3114
|
-
}
|
|
3115
|
-
});
|
|
3116
|
-
const lateActionBase = {
|
|
3117
|
-
actionId: 'legacy-skipped-action',
|
|
3118
|
-
kind: 'search.run',
|
|
3119
|
-
contextRef: 'ctx-v2',
|
|
3120
|
-
expectedRevision: 0,
|
|
3121
|
-
factRefs: [],
|
|
3122
|
-
reasonDigest: bookingV2Digest('legacy search'),
|
|
3123
|
-
inputDigest: bookingV2Digest({}),
|
|
3124
|
-
input: {},
|
|
3125
|
-
eventId: 'legacy-skipped-operation',
|
|
3126
|
-
sequence: 3,
|
|
3127
|
-
emittedAt: '2026-09-01T10:00:02.000Z',
|
|
3128
|
-
sourceTurnId: 'task-legacy-skipped-action-turn-0'
|
|
3129
|
-
};
|
|
3130
|
-
const lateAction = {
|
|
3131
|
-
...lateActionBase,
|
|
3132
|
-
actionDigest: bookingV2Digest(lateActionBase)
|
|
3133
|
-
};
|
|
3134
|
-
const startAvailability = JSON.parse(skippedActionLedger.db.prepare("SELECT payload FROM events WHERE run_id = ? AND kind = 'booking.copilot.v2.task.started'").get(skippedTask.taskId).payload).availability;
|
|
3135
|
-
skippedActionLedger.insertEvent({
|
|
3136
|
-
actor: 'system:booking-copilot-v2',
|
|
3137
|
-
kind: 'booking.copilot.v2.action.issued',
|
|
3138
|
-
subjectId: skippedTask.taskId,
|
|
3139
|
-
runId: skippedTask.taskId,
|
|
3140
|
-
ts: '2026-09-01T10:00:02.000Z',
|
|
3141
|
-
idemKey: 'legacy-skipped-action-row',
|
|
3142
|
-
payload: {
|
|
3143
|
-
schema: 'booking.copilot.ledger.v2',
|
|
3144
|
-
taskId: skippedTask.taskId,
|
|
3145
|
-
contextRef: 'ctx-v2',
|
|
3146
|
-
action: lateAction,
|
|
3147
|
-
operationCount: 99,
|
|
3148
|
-
availability: legacyAvailability(startAvailability),
|
|
3149
|
-
availabilityDigest: bookingV2Digest(legacyAvailability(startAvailability))
|
|
3150
|
-
}
|
|
3151
|
-
});
|
|
3152
|
-
const skippedActionRestart = new BookingCopilotTaskRuntimeV2(ensureLedger(skippedActionRoot), {
|
|
3153
|
-
contextRefFactory: ()=>'ctx-v2',
|
|
3154
|
-
now: ()=>'2026-09-01T10:00:00.000Z'
|
|
3155
|
-
});
|
|
3156
|
-
assert.throws(()=>skippedActionRestart.resumeTask(skippedTask.taskId), /ledger_corrupt:task-legacy-skipped-action:action/, 'tampered skipped ACTION ordinal fails closed during legacy tail replay');
|
|
3157
|
-
skippedActionRestart['ledger'].close();
|
|
3158
|
-
skippedActionLedger.close();
|
|
3159
|
-
rmSync(skippedActionRoot, {
|
|
3160
|
-
recursive: true,
|
|
3161
|
-
force: true
|
|
3162
|
-
});
|
|
3163
|
-
const approvedReplay = await fetch(endpoint, {
|
|
3164
|
-
method: 'POST',
|
|
3165
|
-
headers,
|
|
3166
|
-
body: JSON.stringify(approvedTurn)
|
|
3167
|
-
});
|
|
3168
|
-
assert.equal(approvedReplay.status, 200);
|
|
3169
|
-
assert.equal(await approvedReplay.text(), approvedBody, 'approved user-turn retry replays the durable batch while waiting for receipt');
|
|
3170
|
-
assert.equal(plannerCalls, 3, 'approved user-turn replay does not call planner or append');
|
|
3171
|
-
const alteredTextReplay = await fetch(endpoint, {
|
|
3172
|
-
method: 'POST',
|
|
3173
|
-
headers,
|
|
3174
|
-
body: JSON.stringify({
|
|
3175
|
-
...approvedTurn,
|
|
3176
|
-
request: {
|
|
3177
|
-
...approvedTurn.request,
|
|
3178
|
-
text: 'different request'
|
|
3179
|
-
}
|
|
3180
|
-
})
|
|
3181
|
-
});
|
|
3182
|
-
assert.equal(alteredTextReplay.status, 409, 'altered request text cannot replay a waiting batch');
|
|
3183
|
-
const alteredContextReplay = await fetch(endpoint, {
|
|
3184
|
-
method: 'POST',
|
|
3185
|
-
headers,
|
|
3186
|
-
body: JSON.stringify({
|
|
3187
|
-
...approvedTurn,
|
|
3188
|
-
workspace: {
|
|
3189
|
-
...approvedTurn.workspace,
|
|
3190
|
-
contextRef: 'ctx-forged'
|
|
3191
|
-
}
|
|
3192
|
-
})
|
|
3193
|
-
});
|
|
3194
|
-
assert.equal(alteredContextReplay.status, 409, 'cross-context request cannot replay a waiting batch');
|
|
3195
|
-
const alteredApprovalReplay = await fetch(endpoint, {
|
|
3196
|
-
method: 'POST',
|
|
3197
|
-
headers,
|
|
3198
|
-
body: JSON.stringify({
|
|
3199
|
-
...approvedTurn,
|
|
3200
|
-
request: {
|
|
3201
|
-
...approvedTurn.request,
|
|
3202
|
-
approval: {
|
|
3203
|
-
...approved,
|
|
3204
|
-
to: 'drop'
|
|
3205
|
-
}
|
|
3206
|
-
}
|
|
3207
|
-
})
|
|
3208
|
-
});
|
|
3209
|
-
assert.equal(alteredApprovalReplay.status, 409, 'altered approval tuple cannot replay a waiting batch');
|
|
3210
|
-
const alteredCapabilitiesReplay = await fetch(endpoint, {
|
|
3211
|
-
method: 'POST',
|
|
3212
|
-
headers,
|
|
3213
|
-
body: JSON.stringify({
|
|
3214
|
-
...approvedTurn,
|
|
3215
|
-
workspace: {
|
|
3216
|
-
...approvedTurn.workspace,
|
|
3217
|
-
capabilities: {
|
|
3218
|
-
...approvedTurn.workspace.capabilities,
|
|
3219
|
-
allowedActions: [
|
|
3220
|
-
approvedTurn.workspace.capabilities.allowedActions[0],
|
|
3221
|
-
approvedTurn.workspace.capabilities.allowedActions[0]
|
|
3222
|
-
]
|
|
3223
|
-
}
|
|
3224
|
-
}
|
|
3225
|
-
})
|
|
3226
|
-
});
|
|
3227
|
-
assert.ok([
|
|
3228
|
-
400,
|
|
3229
|
-
409
|
|
3230
|
-
].includes(alteredCapabilitiesReplay.status), 'altered capabilities cannot replay a waiting batch');
|
|
3231
|
-
const ingressReplay = await fetch(endpoint, {
|
|
3232
|
-
method: 'POST',
|
|
3233
|
-
headers,
|
|
3234
|
-
body: JSON.stringify({
|
|
3235
|
-
...ingress,
|
|
3236
|
-
workspace: {
|
|
3237
|
-
...ingress.workspace,
|
|
3238
|
-
revision: 2
|
|
3239
|
-
},
|
|
3240
|
-
request: {
|
|
3241
|
-
text: 'prefer a nearby match'
|
|
3242
|
-
}
|
|
3243
|
-
})
|
|
3244
|
-
});
|
|
3245
|
-
assert.equal(ingressReplay.status, 409, 'unbound ingress cannot replay an existing task');
|
|
3246
|
-
assert.equal(plannerCalls, 3);
|
|
3247
|
-
const v1Conflict = await fetch(endpoint, {
|
|
3248
|
-
method: 'POST',
|
|
3249
|
-
headers: {
|
|
3250
|
-
authorization: 'Bearer v2-server-key',
|
|
3251
|
-
'content-type': 'application/json',
|
|
3252
|
-
'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION,
|
|
3253
|
-
'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_SHA256
|
|
3254
|
-
},
|
|
3255
|
-
body: JSON.stringify({
|
|
3256
|
-
schemaVersion: 'booking.surface.v1',
|
|
3257
|
-
kind: 'user.turn',
|
|
3258
|
-
taskId: 'task-server',
|
|
3259
|
-
workspace: {
|
|
3260
|
-
schemaVersion: 'booking.surface.v1',
|
|
3261
|
-
contextRef: 'ctx-server',
|
|
3262
|
-
surface: 'tenant',
|
|
3263
|
-
revision: 1,
|
|
3264
|
-
locale: 'en-US',
|
|
3265
|
-
currency: 'AED',
|
|
3266
|
-
searchDraft: {},
|
|
3267
|
-
results: {
|
|
3268
|
-
status: 'ready'
|
|
3269
|
-
},
|
|
3270
|
-
visibleHotels: [],
|
|
3271
|
-
loadedOffers: [],
|
|
3272
|
-
shortlistedOfferRefs: [],
|
|
3273
|
-
capabilities: {
|
|
3274
|
-
surface: 'tenant',
|
|
3275
|
-
allowedActions: [
|
|
3276
|
-
'search.run'
|
|
3277
|
-
]
|
|
3278
|
-
}
|
|
3279
|
-
},
|
|
3280
|
-
request: {
|
|
3281
|
-
text: 'v1 must not share a v2 task id'
|
|
3282
|
-
}
|
|
3283
|
-
})
|
|
3284
|
-
});
|
|
3285
|
-
assert.equal(v1Conflict.status, 409, 'one task id cannot cross protocol versions on the unified server');
|
|
3286
|
-
await server.close();
|
|
3287
|
-
const defaultBindingRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-default-binding-'));
|
|
3288
|
-
const defaultBindingLedger = ensureLedger(defaultBindingRoot);
|
|
3289
|
-
const defaultBindingRuntime = new BookingCopilotTaskRuntimeV2(defaultBindingLedger);
|
|
3290
|
-
let defaultBindingPlannerCalls = 0;
|
|
3291
|
-
await assert.rejects(startBookingCopilotServer({
|
|
3292
|
-
apiKey: 'partial-binding-key',
|
|
3293
|
-
v2: {
|
|
3294
|
-
runtime: defaultBindingRuntime,
|
|
3295
|
-
plannerFactory: ()=>({
|
|
3296
|
-
next: async ()=>[]
|
|
3297
|
-
}),
|
|
3298
|
-
principal: {
|
|
3299
|
-
subject: 'partial-only',
|
|
3300
|
-
scope: 'booking:read'
|
|
3301
|
-
}
|
|
3302
|
-
}
|
|
3303
|
-
}), /booking_copilot_v2_ingress_binding_pair_required/);
|
|
3304
|
-
await assert.rejects(startBookingCopilotServer({
|
|
3305
|
-
apiKey: 'partial-principal-key',
|
|
3306
|
-
v2: {
|
|
3307
|
-
runtime: defaultBindingRuntime,
|
|
3308
|
-
plannerFactory: ()=>({
|
|
3309
|
-
next: async ()=>[]
|
|
3310
|
-
}),
|
|
3311
|
-
ingressBinding: {
|
|
3312
|
-
bind: ()=>({
|
|
3313
|
-
taskId: 'partial',
|
|
3314
|
-
turnId: 'partial',
|
|
3315
|
-
contextRef: 'partial',
|
|
3316
|
-
surface: 'tenant',
|
|
3317
|
-
allowedActions: [
|
|
3318
|
-
...BOOKING_READ_ACTION_KINDS_V2
|
|
3319
|
-
]
|
|
3320
|
-
})
|
|
3321
|
-
}
|
|
3322
|
-
}
|
|
3323
|
-
}), /booking_copilot_v2_ingress_binding_pair_required/);
|
|
3324
|
-
const defaultBindingServer = await startBookingCopilotServer({
|
|
3325
|
-
apiKey: 'default-binding-key',
|
|
3326
|
-
runtime: new BookingCopilotTaskRuntime(defaultBindingLedger),
|
|
3327
|
-
plannerFactory: ()=>({
|
|
3328
|
-
next: async ()=>[]
|
|
3329
|
-
}),
|
|
3330
|
-
v2: {
|
|
3331
|
-
runtime: defaultBindingRuntime,
|
|
3332
|
-
plannerFactory: ()=>({
|
|
3333
|
-
next: async ()=>{
|
|
3334
|
-
defaultBindingPlannerCalls++;
|
|
3335
|
-
return [
|
|
3336
|
-
{
|
|
3337
|
-
kind: 'terminal',
|
|
3338
|
-
terminal: {
|
|
3339
|
-
status: 'stopped',
|
|
3340
|
-
summary: 'default binding',
|
|
3341
|
-
factRefs: []
|
|
3342
|
-
}
|
|
3343
|
-
}
|
|
3344
|
-
];
|
|
3345
|
-
}
|
|
3346
|
-
})
|
|
3347
|
-
}
|
|
3348
|
-
});
|
|
3349
|
-
const defaultBindingEndpoint = `http://127.0.0.1:${defaultBindingServer.port}/a2a/booking-copilot/turn`;
|
|
3350
|
-
const defaultBindingHeaders = {
|
|
3351
|
-
...headers,
|
|
3352
|
-
authorization: 'Bearer default-binding-key'
|
|
3353
|
-
};
|
|
3354
|
-
const defaultBindingBeforeIngress = defaultBindingLedger.countEvents();
|
|
3355
|
-
const rejectedDefaultIngress = await fetch(defaultBindingEndpoint, {
|
|
3356
|
-
method: 'POST',
|
|
3357
|
-
headers: defaultBindingHeaders,
|
|
3358
|
-
body: JSON.stringify(ingress)
|
|
3359
|
-
});
|
|
3360
|
-
assert.equal(rejectedDefaultIngress.status, 503, 'bound-turn-only mode rejects browser ingress before the ledger');
|
|
3361
|
-
assert.deepEqual(await rejectedDefaultIngress.json(), {
|
|
3362
|
-
error: {
|
|
3363
|
-
code: 'trusted_ingress_binding_required',
|
|
3364
|
-
mode: 'bff-bound-turn-only',
|
|
3365
|
-
acceptedTurnKinds: [
|
|
3366
|
-
'user.turn',
|
|
3367
|
-
'action.receipt.continuation'
|
|
3368
|
-
]
|
|
3369
|
-
}
|
|
3370
|
-
});
|
|
3371
|
-
assert.equal(defaultBindingLedger.countEvents(), defaultBindingBeforeIngress, 'rejected browser ingress has no ledger side effects');
|
|
3372
|
-
const boundTurnForSurface = (taskId, surface, allowedActions)=>({
|
|
3373
|
-
...turn(taskId),
|
|
3374
|
-
workspace: {
|
|
3375
|
-
...workspace(),
|
|
3376
|
-
surface,
|
|
3377
|
-
capabilities: {
|
|
3378
|
-
surface,
|
|
3379
|
-
allowedActions
|
|
3380
|
-
}
|
|
3381
|
-
}
|
|
3382
|
-
});
|
|
3383
|
-
const invalidBoundTurns = [
|
|
3384
|
-
boundTurnForSurface('task-bound-storefront-escalation', 'storefront', [
|
|
3385
|
-
'order.observe'
|
|
3386
|
-
]),
|
|
3387
|
-
boundTurnForSurface('task-bound-payment-escalation', 'payment_link', [
|
|
3388
|
-
'checkout.prepare'
|
|
3389
|
-
]),
|
|
3390
|
-
{
|
|
3391
|
-
...boundTurnForSurface('task-bound-capability-mismatch', 'storefront', [
|
|
3392
|
-
'search.run'
|
|
3393
|
-
]),
|
|
3394
|
-
workspace: {
|
|
3395
|
-
...workspace(),
|
|
3396
|
-
surface: 'storefront',
|
|
3397
|
-
capabilities: {
|
|
3398
|
-
surface: 'tenant',
|
|
3399
|
-
allowedActions: [
|
|
3400
|
-
'search.run'
|
|
3401
|
-
]
|
|
3402
|
-
}
|
|
3403
|
-
}
|
|
3404
|
-
},
|
|
3405
|
-
boundTurnForSurface('task-bound-duplicate-capability', 'tenant', [
|
|
3406
|
-
'search.run',
|
|
3407
|
-
'search.run'
|
|
3408
|
-
]),
|
|
3409
|
-
boundTurnForSurface('task-bound-unknown-capability', 'tenant', [
|
|
3410
|
-
'book'
|
|
3411
|
-
])
|
|
3412
|
-
];
|
|
3413
|
-
for (const invalidBoundTurn of invalidBoundTurns){
|
|
3414
|
-
const before = defaultBindingLedger.countEvents();
|
|
3415
|
-
const response = await fetch(defaultBindingEndpoint, {
|
|
3416
|
-
method: 'POST',
|
|
3417
|
-
headers: defaultBindingHeaders,
|
|
3418
|
-
body: JSON.stringify(invalidBoundTurn)
|
|
3419
|
-
});
|
|
3420
|
-
assert.ok([
|
|
3421
|
-
400,
|
|
3422
|
-
403
|
|
3423
|
-
].includes(response.status), `${invalidBoundTurn.taskId} is rejected at the schema/authority boundary`);
|
|
3424
|
-
assert.equal(defaultBindingLedger.countEvents(), before, `${invalidBoundTurn.taskId} has no ledger side effects`);
|
|
3425
|
-
}
|
|
3426
|
-
const invalidBoundContinuation = {
|
|
3427
|
-
schemaVersion: 'booking.surface.v2',
|
|
3428
|
-
kind: 'action.receipt.continuation',
|
|
3429
|
-
taskId: 'task-bound-invalid-continuation',
|
|
3430
|
-
workspace: {
|
|
3431
|
-
...workspace(1),
|
|
3432
|
-
surface: 'storefront',
|
|
3433
|
-
capabilities: {
|
|
3434
|
-
surface: 'storefront',
|
|
3435
|
-
allowedActions: [
|
|
3436
|
-
'order.observe'
|
|
3437
|
-
]
|
|
3438
|
-
}
|
|
3439
|
-
},
|
|
3440
|
-
receipt: {
|
|
3441
|
-
schemaVersion: 'booking.surface.v2',
|
|
3442
|
-
kind: 'action.receipt',
|
|
3443
|
-
actionId: 'missing-action',
|
|
3444
|
-
contextRef: 'ctx-v2',
|
|
3445
|
-
status: 'failed',
|
|
3446
|
-
revision: 1,
|
|
3447
|
-
observation: {
|
|
3448
|
-
kind: 'gap',
|
|
3449
|
-
code: 'unhandled',
|
|
3450
|
-
factRefs: []
|
|
3451
|
-
},
|
|
3452
|
-
resultContract: {
|
|
3453
|
-
outcome: 'empty',
|
|
3454
|
-
hardCriteriaMet: false,
|
|
3455
|
-
factRefs: [],
|
|
3456
|
-
gapCodes: [
|
|
3457
|
-
'unhandled'
|
|
3458
|
-
],
|
|
3459
|
-
blockers: [],
|
|
3460
|
-
relaxationsApplied: []
|
|
3461
|
-
}
|
|
3462
|
-
}
|
|
3463
|
-
};
|
|
3464
|
-
const invalidContinuationBefore = defaultBindingLedger.countEvents();
|
|
3465
|
-
const invalidContinuationResponse = await fetch(defaultBindingEndpoint, {
|
|
3466
|
-
method: 'POST',
|
|
3467
|
-
headers: defaultBindingHeaders,
|
|
3468
|
-
body: JSON.stringify(invalidBoundContinuation)
|
|
3469
|
-
});
|
|
3470
|
-
assert.equal(invalidContinuationResponse.status, 403, 'receipt continuation cannot change to a surface-disallowed capability');
|
|
3471
|
-
assert.equal(defaultBindingLedger.countEvents(), invalidContinuationBefore, 'invalid bound continuation has no ledger side effects');
|
|
3472
|
-
assert.equal(defaultBindingPlannerCalls, 0, 'invalid bound turns do not call the planner');
|
|
3473
|
-
for (const [surface, allowedActions] of [
|
|
3474
|
-
[
|
|
3475
|
-
'tenant',
|
|
3476
|
-
[
|
|
3477
|
-
'search.run'
|
|
3478
|
-
]
|
|
3479
|
-
],
|
|
3480
|
-
[
|
|
3481
|
-
'customer_portal',
|
|
3482
|
-
[
|
|
3483
|
-
'offers.query'
|
|
3484
|
-
]
|
|
3485
|
-
],
|
|
3486
|
-
[
|
|
3487
|
-
'storefront',
|
|
3488
|
-
[
|
|
3489
|
-
'search.run'
|
|
3490
|
-
]
|
|
3491
|
-
],
|
|
3492
|
-
[
|
|
3493
|
-
'payment_link',
|
|
3494
|
-
[
|
|
3495
|
-
'hotel.select'
|
|
3496
|
-
]
|
|
3497
|
-
]
|
|
3498
|
-
]){
|
|
3499
|
-
const response = await fetch(defaultBindingEndpoint, {
|
|
3500
|
-
method: 'POST',
|
|
3501
|
-
headers: defaultBindingHeaders,
|
|
3502
|
-
body: JSON.stringify(boundTurnForSurface(`task-bound-valid-${surface}`, surface, allowedActions))
|
|
3503
|
-
});
|
|
3504
|
-
assert.equal(response.status, 200, `valid ${surface} bound turn is accepted`);
|
|
3505
|
-
assert.match(await response.text(), /event: terminal/);
|
|
3506
|
-
}
|
|
3507
|
-
assert.equal(defaultBindingPlannerCalls, 4, 'only valid surface-bound turns call the planner');
|
|
3508
|
-
await defaultBindingServer.close();
|
|
3509
|
-
assert.equal(defaultBindingLedger.countEvents() > defaultBindingBeforeIngress, true);
|
|
3510
|
-
defaultBindingLedger.close();
|
|
3511
|
-
rmSync(defaultBindingRoot, {
|
|
3512
|
-
recursive: true,
|
|
3513
|
-
force: true
|
|
3514
|
-
});
|
|
3515
|
-
const terminalServerRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-http-terminal-replay-'));
|
|
3516
|
-
const terminalServerLedger = ensureLedger(terminalServerRoot);
|
|
3517
|
-
const terminalServerRuntime = new BookingCopilotTaskRuntimeV2(terminalServerLedger);
|
|
3518
|
-
const terminalServer = await startBookingCopilotServer({
|
|
3519
|
-
apiKey: 'terminal-server-key',
|
|
3520
|
-
v2: {
|
|
3521
|
-
runtime: terminalServerRuntime,
|
|
3522
|
-
principal: {
|
|
3523
|
-
subject: 'bff-terminal',
|
|
3524
|
-
scope: 'booking:read'
|
|
3525
|
-
},
|
|
3526
|
-
ingressBinding: {
|
|
3527
|
-
bind: ()=>({
|
|
3528
|
-
taskId: 'task-http-terminal',
|
|
3529
|
-
turnId: 'http-terminal-turn',
|
|
3530
|
-
contextRef: 'ctx-server',
|
|
3531
|
-
surface: 'tenant',
|
|
3532
|
-
allowedActions: [
|
|
3533
|
-
...BOOKING_READ_ACTION_KINDS_V2
|
|
3534
|
-
]
|
|
3535
|
-
})
|
|
3536
|
-
},
|
|
3537
|
-
plannerFactory: ()=>({
|
|
3538
|
-
next: async ()=>[
|
|
3539
|
-
{
|
|
3540
|
-
kind: 'terminal',
|
|
3541
|
-
terminal: {
|
|
3542
|
-
status: 'stopped',
|
|
3543
|
-
summary: 'terminal replay',
|
|
3544
|
-
factRefs: []
|
|
3545
|
-
}
|
|
3546
|
-
}
|
|
3547
|
-
]
|
|
3548
|
-
})
|
|
3549
|
-
}
|
|
3550
|
-
});
|
|
3551
|
-
const terminalIngressEndpoint = `http://127.0.0.1:${terminalServer.port}/a2a/booking-copilot/turn`;
|
|
3552
|
-
const terminalIngress = {
|
|
3553
|
-
...ingress,
|
|
3554
|
-
requestKey: 'terminal-request-1'
|
|
3555
|
-
};
|
|
3556
|
-
const terminalIngressHeaders = {
|
|
3557
|
-
...headers,
|
|
3558
|
-
authorization: 'Bearer terminal-server-key'
|
|
3559
|
-
};
|
|
3560
|
-
const terminalFirst = await fetch(terminalIngressEndpoint, {
|
|
3561
|
-
method: 'POST',
|
|
3562
|
-
headers: terminalIngressHeaders,
|
|
3563
|
-
body: JSON.stringify(terminalIngress)
|
|
3564
|
-
});
|
|
3565
|
-
assert.equal(terminalFirst.status, 200);
|
|
3566
|
-
const terminalFirstBody = await terminalFirst.text();
|
|
3567
|
-
const terminalIngressCount = terminalServerLedger.countEvents();
|
|
3568
|
-
const terminalReplay = await fetch(terminalIngressEndpoint, {
|
|
3569
|
-
method: 'POST',
|
|
3570
|
-
headers: terminalIngressHeaders,
|
|
3571
|
-
body: JSON.stringify(terminalIngress)
|
|
3572
|
-
});
|
|
3573
|
-
assert.equal(terminalReplay.status, 200);
|
|
3574
|
-
assert.equal(await terminalReplay.text(), terminalFirstBody, 'terminal ingress exact request batch replays after terminal');
|
|
3575
|
-
assert.equal(terminalServerLedger.countEvents(), terminalIngressCount);
|
|
3576
|
-
const terminalNewRequest = await fetch(terminalIngressEndpoint, {
|
|
3577
|
-
method: 'POST',
|
|
3578
|
-
headers: terminalIngressHeaders,
|
|
3579
|
-
body: JSON.stringify({
|
|
3580
|
-
...terminalIngress,
|
|
3581
|
-
requestKey: 'terminal-request-2'
|
|
3582
|
-
})
|
|
3583
|
-
});
|
|
3584
|
-
assert.equal(terminalNewRequest.status, 409, 'new terminal ingress request key is rejected');
|
|
3585
|
-
assert.equal(terminalServerLedger.countEvents(), terminalIngressCount);
|
|
3586
|
-
await terminalServer.close();
|
|
3587
|
-
terminalServerLedger.close();
|
|
3588
|
-
rmSync(terminalServerRoot, {
|
|
3589
|
-
recursive: true,
|
|
3590
|
-
force: true
|
|
3591
|
-
});
|
|
3592
|
-
for (const [surface, taskId, allowedActions, hint] of [
|
|
3593
|
-
[
|
|
3594
|
-
'storefront',
|
|
3595
|
-
'task-storefront-escalation',
|
|
3596
|
-
[
|
|
3597
|
-
...BOOKING_READ_ACTION_KINDS_V2
|
|
3598
|
-
],
|
|
3599
|
-
'storefront'
|
|
3600
|
-
],
|
|
3601
|
-
[
|
|
3602
|
-
'payment_link',
|
|
3603
|
-
'task-payment-escalation',
|
|
3604
|
-
[
|
|
3605
|
-
...BOOKING_READ_ACTION_KINDS_V2
|
|
3606
|
-
],
|
|
3607
|
-
'payment_link'
|
|
3608
|
-
],
|
|
3609
|
-
[
|
|
3610
|
-
'tenant',
|
|
3611
|
-
'task-surface-mismatch',
|
|
3612
|
-
[
|
|
3613
|
-
...BOOKING_READ_ACTION_KINDS_V2
|
|
3614
|
-
],
|
|
3615
|
-
'storefront'
|
|
3616
|
-
]
|
|
3617
|
-
]){
|
|
3618
|
-
const matrixRoot = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${taskId}-`));
|
|
3619
|
-
const matrixLedger = ensureLedger(matrixRoot);
|
|
3620
|
-
const matrixRuntime = new BookingCopilotTaskRuntimeV2(matrixLedger);
|
|
3621
|
-
const matrixServer = await startBookingCopilotServer({
|
|
3622
|
-
apiKey: `${taskId}-key`,
|
|
3623
|
-
v2: {
|
|
3624
|
-
runtime: matrixRuntime,
|
|
3625
|
-
principal: {
|
|
3626
|
-
subject: 'bff-matrix',
|
|
3627
|
-
scope: 'booking:read'
|
|
3628
|
-
},
|
|
3629
|
-
ingressBinding: {
|
|
3630
|
-
bind: ()=>({
|
|
3631
|
-
taskId,
|
|
3632
|
-
turnId: `${taskId}-turn`,
|
|
3633
|
-
contextRef: 'ctx-server',
|
|
3634
|
-
surface,
|
|
3635
|
-
allowedActions: [
|
|
3636
|
-
...allowedActions
|
|
3637
|
-
]
|
|
3638
|
-
})
|
|
3639
|
-
},
|
|
3640
|
-
plannerFactory: ()=>({
|
|
3641
|
-
next: async ()=>[
|
|
3642
|
-
{
|
|
3643
|
-
kind: 'terminal',
|
|
3644
|
-
terminal: {
|
|
3645
|
-
status: 'stopped',
|
|
3646
|
-
summary: 'must not plan',
|
|
3647
|
-
factRefs: []
|
|
3648
|
-
}
|
|
3649
|
-
}
|
|
3650
|
-
]
|
|
3651
|
-
})
|
|
3652
|
-
}
|
|
3653
|
-
});
|
|
3654
|
-
const matrixResponse = await fetch(`http://127.0.0.1:${matrixServer.port}/a2a/booking-copilot/turn`, {
|
|
3655
|
-
method: 'POST',
|
|
3656
|
-
headers: {
|
|
3657
|
-
...headers,
|
|
3658
|
-
authorization: `Bearer ${taskId}-key`
|
|
3659
|
-
},
|
|
3660
|
-
body: JSON.stringify({
|
|
3661
|
-
...ingress,
|
|
3662
|
-
requestKey: `${taskId}-request`,
|
|
3663
|
-
surfaceHint: hint
|
|
3664
|
-
})
|
|
3665
|
-
});
|
|
3666
|
-
assert.equal(matrixResponse.status, 502, `${surface} binding escalation/mismatch is rejected before planning`);
|
|
3667
|
-
assert.equal(matrixLedger.countEvents(), 0);
|
|
3668
|
-
await matrixServer.close();
|
|
3669
|
-
matrixLedger.close();
|
|
3670
|
-
rmSync(matrixRoot, {
|
|
3671
|
-
recursive: true,
|
|
3672
|
-
force: true
|
|
3673
|
-
});
|
|
3674
|
-
}
|
|
3675
|
-
for (const [label, plannerFactory, expectedCode] of [
|
|
3676
|
-
[
|
|
3677
|
-
'runtime-unsupported-action',
|
|
3678
|
-
()=>({
|
|
3679
|
-
next: async ()=>[
|
|
3680
|
-
{
|
|
3681
|
-
kind: 'operation',
|
|
3682
|
-
action: {
|
|
3683
|
-
...action('disallowed-http-action'),
|
|
3684
|
-
kind: 'offers.query',
|
|
3685
|
-
input: {
|
|
3686
|
-
hotelRefs: [
|
|
3687
|
-
'hotel-a'
|
|
3688
|
-
],
|
|
3689
|
-
criteria: {}
|
|
3690
|
-
}
|
|
3691
|
-
}
|
|
3692
|
-
}
|
|
3693
|
-
]
|
|
3694
|
-
}),
|
|
3695
|
-
'UNSUPPORTED_ACTION'
|
|
3696
|
-
],
|
|
3697
|
-
[
|
|
3698
|
-
'planner-surface-unsupported',
|
|
3699
|
-
()=>({
|
|
3700
|
-
next: async ()=>{
|
|
3701
|
-
throw new Error('planner_surface_action_unsupported: raw internal detail');
|
|
3702
|
-
}
|
|
3703
|
-
}),
|
|
3704
|
-
'PLANNER_SURFACE_ACTION_UNSUPPORTED'
|
|
3705
|
-
]
|
|
3706
|
-
]){
|
|
3707
|
-
const errorRoot = mkdtempSync(join(tmpdir(), `gotry-booking-v2-${label}-`));
|
|
3708
|
-
const errorLedger = ensureLedger(errorRoot);
|
|
3709
|
-
const errorRuntime = new BookingCopilotTaskRuntimeV2(errorLedger);
|
|
3710
|
-
const errorTaskId = `task-${label}`;
|
|
3711
|
-
let errorPlannerCalls = 0;
|
|
3712
|
-
const errorServer = await startBookingCopilotServer({
|
|
3713
|
-
apiKey: `${label}-key`,
|
|
3714
|
-
v2: {
|
|
3715
|
-
runtime: errorRuntime,
|
|
3716
|
-
principal: {
|
|
3717
|
-
subject: `bff-${label}`,
|
|
3718
|
-
scope: 'booking:read'
|
|
3719
|
-
},
|
|
3720
|
-
ingressBinding: {
|
|
3721
|
-
bind: ()=>({
|
|
3722
|
-
taskId: errorTaskId,
|
|
3723
|
-
turnId: `${errorTaskId}-turn`,
|
|
3724
|
-
contextRef: 'ctx-v2',
|
|
3725
|
-
surface: 'tenant',
|
|
3726
|
-
allowedActions: [
|
|
3727
|
-
'search.run'
|
|
3728
|
-
]
|
|
3729
|
-
})
|
|
3730
|
-
},
|
|
3731
|
-
plannerFactory: (_initial)=>{
|
|
3732
|
-
const session = plannerFactory();
|
|
3733
|
-
return {
|
|
3734
|
-
next: async (_input)=>{
|
|
3735
|
-
errorPlannerCalls++;
|
|
3736
|
-
return session.next();
|
|
3737
|
-
}
|
|
3738
|
-
};
|
|
3739
|
-
}
|
|
3740
|
-
}
|
|
3741
|
-
});
|
|
3742
|
-
const errorTurn = {
|
|
3743
|
-
...turn(errorTaskId),
|
|
3744
|
-
workspace: {
|
|
3745
|
-
...workspace(),
|
|
3746
|
-
capabilities: {
|
|
3747
|
-
surface: 'tenant',
|
|
3748
|
-
allowedActions: [
|
|
3749
|
-
'search.run'
|
|
3750
|
-
]
|
|
3751
|
-
}
|
|
3752
|
-
}
|
|
3753
|
-
};
|
|
3754
|
-
const errorResponse = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
|
|
3755
|
-
method: 'POST',
|
|
3756
|
-
headers: {
|
|
3757
|
-
...headers,
|
|
3758
|
-
authorization: `Bearer ${label}-key`
|
|
3759
|
-
},
|
|
3760
|
-
body: JSON.stringify(errorTurn)
|
|
3761
|
-
});
|
|
3762
|
-
assert.equal(errorResponse.status, 200, `${label} keeps the committed SSE status while returning a typed error`);
|
|
3763
|
-
const errorBody = await errorResponse.text();
|
|
3764
|
-
assert.match(errorBody, /event: error/, `${label} never returns an empty SSE body`);
|
|
3765
|
-
assert.match(errorBody, new RegExp(`"code":"${expectedCode}"`), `${label} normalizes to the closed uppercase code`);
|
|
3766
|
-
assert.doesNotMatch(errorBody, /raw internal detail/, `${label} does not expose internal error text`);
|
|
3767
|
-
assert.doesNotMatch(errorBody, /event: operation/, `${label} emits no disallowed operation`);
|
|
3768
|
-
const actionRows = errorLedger.db.prepare("SELECT count(*) AS count FROM events WHERE kind = 'booking.copilot.v2.action.issued'").get();
|
|
3769
|
-
assert.equal(actionRows.count, 0, `${label} has zero disallowed operation side effect`);
|
|
3770
|
-
const afterErrorRows = errorLedger.countEvents();
|
|
3771
|
-
assert.equal(errorPlannerCalls, 1, `${label} calls planner once before durable typed error`);
|
|
3772
|
-
const errorReplay = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
|
|
3773
|
-
method: 'POST',
|
|
3774
|
-
headers: {
|
|
3775
|
-
...headers,
|
|
3776
|
-
authorization: `Bearer ${label}-key`
|
|
3777
|
-
},
|
|
3778
|
-
body: JSON.stringify(errorTurn)
|
|
3779
|
-
});
|
|
3780
|
-
assert.equal(errorReplay.status, 200, `${label} terminal typed error request replays as SSE, not HTTP conflict`);
|
|
3781
|
-
assert.equal(await errorReplay.text(), errorBody, `${label} typed error replay is byte-identical`);
|
|
3782
|
-
assert.equal(errorLedger.countEvents(), afterErrorRows, `${label} typed error replay appends zero ledger rows`);
|
|
3783
|
-
assert.equal(errorPlannerCalls, 1, `${label} typed error replay does not call planner again`);
|
|
3784
|
-
const errorConflict = await fetch(`http://127.0.0.1:${errorServer.port}/a2a/booking-copilot/turn`, {
|
|
3785
|
-
method: 'POST',
|
|
3786
|
-
headers: {
|
|
3787
|
-
...headers,
|
|
3788
|
-
authorization: `Bearer ${label}-key`
|
|
3789
|
-
},
|
|
3790
|
-
body: JSON.stringify({
|
|
3791
|
-
...errorTurn,
|
|
3792
|
-
request: {
|
|
3793
|
-
text: `${errorTurn.request.text} changed`
|
|
3794
|
-
}
|
|
3795
|
-
})
|
|
3796
|
-
});
|
|
3797
|
-
assert.equal(errorConflict.status, 409, `${label} same turn identity with changed body remains a conflict`);
|
|
3798
|
-
await errorServer.close();
|
|
3799
|
-
errorLedger.close();
|
|
3800
|
-
rmSync(errorRoot, {
|
|
3801
|
-
recursive: true,
|
|
3802
|
-
force: true
|
|
3803
|
-
});
|
|
3804
|
-
}
|
|
3805
|
-
const mixedRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-mixed-startup-'));
|
|
3806
|
-
const mixedLedger = ensureLedger(mixedRoot);
|
|
3807
|
-
const mixedRuntimeV2 = new BookingCopilotTaskRuntimeV2(mixedLedger);
|
|
3808
|
-
const mixedServer = await startBookingCopilotServer({
|
|
3809
|
-
apiKey: 'mixed-v2-key',
|
|
3810
|
-
runtime: new BookingCopilotTaskRuntime(mixedLedger),
|
|
3811
|
-
plannerFactory: ()=>({
|
|
3812
|
-
next: async ()=>[]
|
|
3813
|
-
}),
|
|
3814
|
-
v2: {
|
|
3815
|
-
runtime: mixedRuntimeV2,
|
|
3816
|
-
plannerFactory: ()=>({
|
|
3817
|
-
next: async ()=>[]
|
|
3818
|
-
})
|
|
3819
|
-
}
|
|
3820
|
-
});
|
|
3821
|
-
await mixedServer.close();
|
|
3822
|
-
mixedLedger.close();
|
|
3823
|
-
rmSync(mixedRoot, {
|
|
3824
|
-
recursive: true,
|
|
3825
|
-
force: true
|
|
3826
|
-
});
|
|
3827
|
-
const v2OnlyRoot = mkdtempSync(join(tmpdir(), 'gotry-booking-v2-only-'));
|
|
3828
|
-
const v2OnlyLedger = ensureLedger(v2OnlyRoot);
|
|
3829
|
-
const v2OnlyRuntime = new BookingCopilotTaskRuntimeV2(v2OnlyLedger);
|
|
3830
|
-
const boundOnlyV2Server = await startBookingCopilotServer({
|
|
3831
|
-
apiKey: 'v2-only-key',
|
|
3832
|
-
v2: {
|
|
3833
|
-
runtime: v2OnlyRuntime,
|
|
3834
|
-
plannerFactory: ()=>({
|
|
3835
|
-
next: async ()=>[]
|
|
3836
|
-
})
|
|
3837
|
-
}
|
|
3838
|
-
});
|
|
3839
|
-
await boundOnlyV2Server.close();
|
|
3840
|
-
const v2OnlyServer = await startBookingCopilotServer({
|
|
3841
|
-
apiKey: 'v2-only-key',
|
|
3842
|
-
v2: {
|
|
3843
|
-
runtime: v2OnlyRuntime,
|
|
3844
|
-
principal: {
|
|
3845
|
-
subject: 'bff-v2-only',
|
|
3846
|
-
scope: 'booking:read'
|
|
3847
|
-
},
|
|
3848
|
-
ingressBinding: {
|
|
3849
|
-
bind: ()=>({
|
|
3850
|
-
taskId: 'task-v2-only',
|
|
3851
|
-
turnId: 'turn-v2-only',
|
|
3852
|
-
contextRef: 'ctx-v2-only',
|
|
3853
|
-
surface: 'tenant',
|
|
3854
|
-
allowedActions: [
|
|
3855
|
-
...BOOKING_READ_ACTION_KINDS_V2
|
|
3856
|
-
]
|
|
3857
|
-
})
|
|
3858
|
-
},
|
|
3859
|
-
plannerFactory: ()=>({
|
|
3860
|
-
next: async ()=>[]
|
|
3861
|
-
})
|
|
3862
|
-
}
|
|
3863
|
-
});
|
|
3864
|
-
const v2Health = await fetch(`http://127.0.0.1:${v2OnlyServer.port}/healthz`, {
|
|
3865
|
-
headers: {
|
|
3866
|
-
authorization: 'Bearer v2-only-key'
|
|
3867
|
-
}
|
|
3868
|
-
});
|
|
3869
|
-
assert.equal(v2Health.status, 200);
|
|
3870
|
-
assert.deepEqual(await v2Health.json(), {
|
|
3871
|
-
schemaVersion: 'booking.surface.v2',
|
|
3872
|
-
schemaSha256: BOOKING_SURFACE_SCHEMA_V2_SHA256,
|
|
3873
|
-
supportedSchemaVersions: [
|
|
3874
|
-
'booking.surface.v2'
|
|
3875
|
-
],
|
|
3876
|
-
status: 'ready',
|
|
3877
|
-
ingressMode: 'bff-ingress-binding',
|
|
3878
|
-
acceptedTurnKinds: [
|
|
3879
|
-
'user.turn',
|
|
3880
|
-
'action.receipt.continuation',
|
|
3881
|
-
'user.turn.ingress'
|
|
3882
|
-
]
|
|
3883
|
-
}, 'v2-only health reports the complete trusted ingress mode');
|
|
3884
|
-
await v2OnlyServer.close();
|
|
3885
|
-
v2OnlyLedger.close();
|
|
3886
|
-
rmSync(v2OnlyRoot, {
|
|
3887
|
-
recursive: true,
|
|
3888
|
-
force: true
|
|
3889
|
-
});
|
|
3890
|
-
runtime['ledger'].close();
|
|
3891
|
-
awaitingRestart['ledger'].close();
|
|
3892
|
-
reopened['ledger'].close();
|
|
3893
|
-
serverRuntime['ledger'].close();
|
|
3894
|
-
crashLedger.close();
|
|
3895
|
-
reverseLedger.close();
|
|
3896
|
-
terminalLedger.close();
|
|
3897
|
-
forgedSourceRuntime['ledger'].close();
|
|
3898
|
-
tamperLedger.close();
|
|
3899
|
-
offerRuntime['ledger'].close();
|
|
3900
|
-
rmSync(stateRoot, {
|
|
3901
|
-
recursive: true,
|
|
3902
|
-
force: true
|
|
3903
|
-
});
|
|
3904
|
-
rmSync(serverRoot, {
|
|
3905
|
-
recursive: true,
|
|
3906
|
-
force: true
|
|
3907
|
-
});
|
|
3908
|
-
rmSync(forgedSourceRoot, {
|
|
3909
|
-
recursive: true,
|
|
3910
|
-
force: true
|
|
3911
|
-
});
|
|
3912
|
-
rmSync(tamperRoot, {
|
|
3913
|
-
recursive: true,
|
|
3914
|
-
force: true
|
|
3915
|
-
});
|
|
3916
|
-
rmSync(crashRoot, {
|
|
3917
|
-
recursive: true,
|
|
3918
|
-
force: true
|
|
3919
|
-
});
|
|
3920
|
-
rmSync(reverseRoot, {
|
|
3921
|
-
recursive: true,
|
|
3922
|
-
force: true
|
|
3923
|
-
});
|
|
3924
|
-
rmSync(terminalRoot, {
|
|
3925
|
-
recursive: true,
|
|
3926
|
-
force: true
|
|
3927
|
-
});
|
|
3928
|
-
rmSync(offerRoot, {
|
|
3929
|
-
recursive: true,
|
|
3930
|
-
force: true
|
|
3931
|
-
});
|
|
3932
|
-
console.log('BOOKING COPILOT V2 RUNTIME PROOF: task scope, receipt binding, approval authority/one-time, recovery, ingress/SSE session OK');
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
//# sourceURL=ts/scripts/booking-copilot-v2-runtime-proof-tests.ts
|