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