@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
|
@@ -1,483 +1,1483 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Durable, task-scoped runtime for the embedded Booking Copilot.
|
|
3
|
-
*
|
|
4
|
-
* This is deliberately not a chat transcript. The ledger stores task identity,
|
|
5
|
-
* typed operation metadata, typed receipts and digests of prose/payloads. Raw
|
|
6
|
-
* user text and raw action criteria never enter durable state.
|
|
7
|
-
*/
|
|
1
|
+
/** Durable, task-scoped runtime for the embedded Booking Copilot. */
|
|
8
2
|
|
|
9
3
|
import { createHash, randomUUID } from 'node:crypto'
|
|
10
4
|
import type { StateLedger } from '../state-ledger.ts'
|
|
11
5
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
type
|
|
15
|
-
type
|
|
16
|
-
type
|
|
17
|
-
type
|
|
18
|
-
type
|
|
19
|
-
type
|
|
20
|
-
type
|
|
21
|
-
type
|
|
22
|
-
type
|
|
23
|
-
type
|
|
24
|
-
type
|
|
6
|
+
BOOKING_READ_ACTION_KINDS,
|
|
7
|
+
BOOKING_COPILOT_MAX_OPERATIONS,
|
|
8
|
+
type ActionReceipt,
|
|
9
|
+
type BookingCopilotTurn,
|
|
10
|
+
type BookingReadAction,
|
|
11
|
+
type BookingReadActionKind,
|
|
12
|
+
type BookingSurfaceEvent,
|
|
13
|
+
type BookingSurface,
|
|
14
|
+
type BookingWorkspaceSnapshot,
|
|
15
|
+
type BookingWorkspaceIngressSnapshot,
|
|
16
|
+
type BookingRequestKey,
|
|
17
|
+
type BookingInternalDecisionKey,
|
|
18
|
+
type BookingIngressPrincipal,
|
|
19
|
+
type CriterionBlocker,
|
|
20
|
+
type IngressTurn,
|
|
21
|
+
type RelaxationApprovalRef,
|
|
22
|
+
type RelaxationApproval,
|
|
23
|
+
type UserTurn,
|
|
25
24
|
} from './contracts.ts'
|
|
26
25
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
validateApprovalAgainstBlocker,
|
|
27
|
+
validateCriterionBlocker,
|
|
28
|
+
validateBookingReadAction,
|
|
29
|
+
validateBookingSurfaceEvent,
|
|
30
|
+
validateBookingSurface,
|
|
31
|
+
isBookingDateTime,
|
|
32
|
+
isSafeBookingRequestKey,
|
|
31
33
|
} from './validation.ts'
|
|
34
|
+
import {
|
|
35
|
+
assertWorkspaceLoadedOfferRefsUnique,
|
|
36
|
+
canIssueOfferCheck,
|
|
37
|
+
createAvailabilityPolicy,
|
|
38
|
+
reduceAvailabilityAction,
|
|
39
|
+
reduceAvailabilityReceipt,
|
|
40
|
+
validateOffersReceiptWorkspace,
|
|
41
|
+
availabilityPolicyResult,
|
|
42
|
+
validateAvailabilityPolicy,
|
|
43
|
+
type AvailabilityExhaustion,
|
|
44
|
+
type AvailabilityPolicyState,
|
|
45
|
+
} from './availability-policy.ts'
|
|
32
46
|
import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.ts'
|
|
33
47
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
const LEDGER_SCHEMA = 'booking.copilot.ledger' as const
|
|
49
|
+
const ACTOR = 'system:booking-copilot'
|
|
50
|
+
const STARTED = 'booking.copilot.task.started'
|
|
51
|
+
const TURN = 'booking.copilot.user.turn.observed'
|
|
52
|
+
const ACTION = 'booking.copilot.action.issued'
|
|
53
|
+
const RECEIPT = 'booking.copilot.receipt.observed'
|
|
54
|
+
const EVENT = 'booking.copilot.event.emitted'
|
|
55
|
+
const APPROVAL_GRANTED = 'booking.copilot.approval.granted'
|
|
56
|
+
const APPROVAL_OFFERED = 'booking.copilot.approval.option.offered'
|
|
57
|
+
const APPROVAL_CONSUMED = 'booking.copilot.approval.consumed'
|
|
58
|
+
const DECISION_BATCH = 'booking.copilot.decision.batch'
|
|
59
|
+
const REQUEST_BINDING = 'booking.copilot.request.binding'
|
|
41
60
|
|
|
42
|
-
|
|
43
|
-
const LEDGER_ACTOR = 'system:booking-copilot'
|
|
44
|
-
const TASK_STARTED = 'booking.copilot.task.started'
|
|
45
|
-
const USER_TURN_OBSERVED = 'booking.copilot.user.turn.observed'
|
|
46
|
-
const ACTION_ISSUED = 'booking.copilot.action.issued'
|
|
47
|
-
const RECEIPT_OBSERVED = 'booking.copilot.receipt.observed'
|
|
48
|
-
const EVENT_EMITTED = 'booking.copilot.event.emitted'
|
|
61
|
+
export type BookingCopilotTaskPhase = 'planning' | 'submitted' | 'working' | 'waiting_receipt' | 'input_required' | 'terminal' | 'error'
|
|
49
62
|
|
|
50
|
-
export
|
|
63
|
+
export interface BookingApprovalState {
|
|
64
|
+
approval?: RelaxationApproval
|
|
65
|
+
blocker: CriterionBlocker
|
|
66
|
+
options: RelaxationApproval[]
|
|
67
|
+
optionsEmitted: boolean
|
|
68
|
+
nonce: string
|
|
69
|
+
expiresAt: string
|
|
70
|
+
sourceTurnId: string
|
|
71
|
+
presentationRequestKey: string
|
|
72
|
+
}
|
|
51
73
|
|
|
52
|
-
export interface
|
|
74
|
+
export interface BookingActionCheckpoint {
|
|
53
75
|
actionId: string
|
|
54
|
-
kind:
|
|
76
|
+
kind: BookingReadActionKind
|
|
55
77
|
contextRef: string
|
|
56
78
|
expectedRevision: number
|
|
57
79
|
factRefs: string[]
|
|
80
|
+
input: Record<string, any>
|
|
58
81
|
reasonDigest: string
|
|
59
82
|
inputDigest: string
|
|
60
83
|
actionDigest: string
|
|
61
84
|
eventId: string
|
|
62
85
|
sequence: number
|
|
63
86
|
emittedAt: string
|
|
87
|
+
sourceTurnId: string
|
|
88
|
+
relaxationApprovalRef?: RelaxationApprovalRef
|
|
64
89
|
}
|
|
65
90
|
|
|
66
|
-
export interface
|
|
67
|
-
schemaVersion: 'booking.surface
|
|
91
|
+
export interface BookingCopilotTaskState {
|
|
92
|
+
schemaVersion: 'booking.surface'
|
|
68
93
|
taskId: string
|
|
69
94
|
contextRef: string
|
|
70
|
-
surface:
|
|
95
|
+
surface: BookingSurface
|
|
71
96
|
revision: number
|
|
72
|
-
allowedActions:
|
|
97
|
+
allowedActions: BookingReadActionKind[]
|
|
73
98
|
userTurnCount: number
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
/** Durable task-level operation ordinal, folded from action ledger rows. */
|
|
100
|
+
operationCount: number
|
|
101
|
+
lastTurnId?: string
|
|
102
|
+
phase: BookingCopilotTaskPhase
|
|
76
103
|
lastSequence: number
|
|
77
|
-
pendingAction?:
|
|
78
|
-
lastReceipt?:
|
|
104
|
+
pendingAction?: BookingActionCheckpoint
|
|
105
|
+
lastReceipt?: ActionReceipt
|
|
106
|
+
awaitingApproval?: BookingApprovalState
|
|
107
|
+
workspaceDigest?: string
|
|
108
|
+
workspaceSemanticDigest?: string
|
|
109
|
+
workspaceSnapshot?: BookingWorkspaceSnapshot
|
|
110
|
+
availability: AvailabilityPolicyState
|
|
111
|
+
replayUpgradeRequired?: boolean
|
|
112
|
+
legacySuppressedDecisionRequestKeys?: string[]
|
|
113
|
+
legacySuppressedRequestBindingKeys?: string[]
|
|
114
|
+
legacySuppressedReceiptActionIds?: string[]
|
|
115
|
+
legacySuppressedApprovalTargetActionIds?: string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type BookingSurfaceEventDraft =
|
|
119
|
+
| Pick<Extract<BookingSurfaceEvent, { kind: 'status' }>, 'kind' | 'status'>
|
|
120
|
+
| Pick<Extract<BookingSurfaceEvent, { kind: 'question' }>, 'kind' | 'question'>
|
|
121
|
+
| Pick<Extract<BookingSurfaceEvent, { kind: 'explanation' }>, 'kind' | 'explanation'>
|
|
122
|
+
| Pick<Extract<BookingSurfaceEvent, { kind: 'terminal' }>, 'kind' | 'terminal'>
|
|
123
|
+
| Pick<Extract<BookingSurfaceEvent, { kind: 'error' }>, 'kind' | 'error'>
|
|
124
|
+
|
|
125
|
+
export type BookingPlannerDecision =
|
|
126
|
+
| { kind: 'operation'; action: BookingReadAction }
|
|
127
|
+
| BookingSurfaceEventDraft
|
|
128
|
+
|
|
129
|
+
export interface BookingPlannerSession {
|
|
130
|
+
next(input: { turn: BookingCopilotTurn; task: BookingCopilotTaskState }): Promise<readonly BookingPlannerDecision[]>
|
|
79
131
|
}
|
|
80
132
|
|
|
81
|
-
export type
|
|
82
|
-
| Pick<BookingStatusEventV1, 'kind' | 'status'>
|
|
83
|
-
| Pick<BookingQuestionEventV1, 'kind' | 'question'>
|
|
84
|
-
| Pick<BookingExplanationEventV1, 'kind' | 'explanation'>
|
|
85
|
-
| Pick<BookingTerminalEventV1, 'kind' | 'terminal'>
|
|
86
|
-
| Pick<BookingErrorEventV1, 'kind' | 'error'>
|
|
133
|
+
export type BookingPlannerSessionFactory = (initialTask: BookingCopilotTaskState) => BookingPlannerSession
|
|
87
134
|
|
|
88
135
|
export interface BookingCopilotRuntimeOptions {
|
|
89
136
|
idFactory?: (prefix: string) => string
|
|
137
|
+
contextRefFactory?: () => string
|
|
90
138
|
now?: () => string
|
|
139
|
+
approvalTtlMs?: number
|
|
91
140
|
}
|
|
92
141
|
|
|
93
|
-
interface
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
interface Row { seq: number; kind: string; payload: string }
|
|
143
|
+
interface BasePayload { schema: typeof LEDGER_SCHEMA; taskId: string; contextRef: string; [key: string]: unknown }
|
|
144
|
+
interface StartedPayload extends BasePayload { surface: BookingSurface; revision: number; allowedActions: BookingReadActionKind[]; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; availability: AvailabilityPolicyState; availabilityDigest: string }
|
|
145
|
+
interface TurnPayload extends BasePayload { requestDigest: string; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; turnId: string }
|
|
146
|
+
interface ActionPayload extends BasePayload { action: BookingActionCheckpoint; operationCount?: number; availability: AvailabilityPolicyState; availabilityDigest: string }
|
|
147
|
+
interface ReceiptPayload extends BasePayload { receipt: ActionReceipt; receiptDigest: string; operationCount?: number; workspaceDigest: string; workspaceSemanticDigest: string; workspace: BookingWorkspaceSnapshot; availability: AvailabilityPolicyState; availabilityDigest: string; availabilityTerminal?: AvailabilityExhaustion }
|
|
148
|
+
interface EventPayload extends BasePayload { eventId: string; sequence: number; emittedAt: string; eventKind: Exclude<BookingSurfaceEvent['kind'], 'operation'>; status?: 'submitted'|'working'|'waiting_receipt'|'input_required'; contentDigest: string }
|
|
149
|
+
interface ApprovalPayload extends BasePayload { approval: BookingApprovalState; ref?: RelaxationApprovalRef; approvalDigest: string }
|
|
150
|
+
interface DecisionBatchPayload extends BasePayload { requestKey: string; events: BookingSurfaceEvent[] }
|
|
151
|
+
interface RequestBindingPayload extends BasePayload { requestKey: BookingRequestKey; requestDigest: string; turnId: string; workspaceDigest: string; workspaceSemanticDigest: string; surface: BookingSurface; capabilityDigest: string; principalDigest: string; scopeDigest: string; taskHandle?: string }
|
|
152
|
+
export interface BookingIngressRequestBindingInput { requestKey: BookingRequestKey; principal: BookingIngressPrincipal; taskHandle?: string }
|
|
153
|
+
interface ReplayConsumedIdentities { events: Map<string, EventPayload>; actions: Map<string, BookingActionCheckpoint>; pendingBatchKeys: Set<string>; pendingBatchOpen: boolean }
|
|
154
|
+
|
|
155
|
+
function stable(value: unknown): unknown {
|
|
156
|
+
if (Array.isArray(value)) return value.map(stable)
|
|
157
|
+
if (!value || typeof value !== 'object') return value
|
|
158
|
+
const out: Record<string, unknown> = {}
|
|
159
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) out[key] = stable((value as Record<string, unknown>)[key])
|
|
160
|
+
return out
|
|
97
161
|
}
|
|
98
162
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
revision: number
|
|
102
|
-
allowedActions: BookingReadActionKindV1[]
|
|
163
|
+
export function bookingDigest(value: unknown): string {
|
|
164
|
+
return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex')
|
|
103
165
|
}
|
|
104
166
|
|
|
105
|
-
|
|
106
|
-
|
|
167
|
+
export function bookingWorkspaceDigest(workspace: BookingWorkspaceSnapshot | BookingWorkspaceIngressSnapshot): string {
|
|
168
|
+
const { contextRef: _contextRef, ...contextBoundWorkspace } = workspace as Record<string, unknown>
|
|
169
|
+
return bookingDigest(contextBoundWorkspace)
|
|
107
170
|
}
|
|
108
171
|
|
|
109
|
-
|
|
110
|
-
|
|
172
|
+
function bookingWorkspaceSemanticDigest(workspace: BookingWorkspaceSnapshot | BookingWorkspaceIngressSnapshot): string {
|
|
173
|
+
const { contextRef: _contextRef, revision: _revision, ...semanticWorkspace } = workspace as Record<string, unknown>
|
|
174
|
+
return bookingDigest(semanticWorkspace)
|
|
111
175
|
}
|
|
112
176
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
receiptDigest: string
|
|
177
|
+
export function bookingTurnDigest(turn: Extract<BookingCopilotTurn, { kind: 'user.turn' | 'user.turn.ingress' }>): string {
|
|
178
|
+
return bookingDigest(turn)
|
|
116
179
|
}
|
|
117
180
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
181
|
+
function canonicalReceiptDigest(value: ActionReceipt): string { return bookingDigest(value) }
|
|
182
|
+
function replayDigestMatches(recordedDigest: unknown, raw: unknown, normalized: unknown): boolean {
|
|
183
|
+
return typeof recordedDigest === 'string' && (recordedDigest === bookingDigest(raw) || recordedDigest === bookingDigest(normalized))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function replayWorkspaceDigestMatches(recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): boolean {
|
|
187
|
+
return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceDigest(raw) || recordedDigest === bookingWorkspaceDigest(normalized))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function replayWorkspaceSemanticDigestMatches(recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): boolean {
|
|
191
|
+
return typeof recordedDigest === 'string' && (recordedDigest === bookingWorkspaceSemanticDigest(raw) || recordedDigest === bookingWorkspaceSemanticDigest(normalized))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function rememberReplayWorkspaceDigest(digests: Map<string, string>, recordedDigest: unknown, raw: BookingWorkspaceSnapshot, normalized: BookingWorkspaceSnapshot): void {
|
|
195
|
+
const normalizedDigest = bookingWorkspaceDigest(normalized)
|
|
196
|
+
digests.set(bookingWorkspaceDigest(raw), normalizedDigest)
|
|
197
|
+
if (typeof recordedDigest === 'string') digests.set(recordedDigest, normalizedDigest)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function legacyOfferVersionRef(contextRef: string, offer: Record<string, unknown>): string {
|
|
201
|
+
return `legacy-offer-version:${bookingDigest({
|
|
202
|
+
contextRef,
|
|
203
|
+
offerRef: offer.offerRef,
|
|
204
|
+
hotelRef: offer.hotelRef,
|
|
205
|
+
evidenceLevel: offer.evidenceLevel,
|
|
206
|
+
factRefs: Array.isArray(offer.factRefs) ? offer.factRefs : [],
|
|
207
|
+
}).slice(0, 40)}`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeWorkspaceForReplay(raw: BookingWorkspaceSnapshot): BookingWorkspaceSnapshot {
|
|
211
|
+
const candidate = raw as unknown as Record<string, unknown>
|
|
212
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(candidate.loadedOffers) || typeof candidate.contextRef !== 'string') throw new Error('booking_malformed_workspace')
|
|
213
|
+
const workspace = structuredClone(raw) as BookingWorkspaceSnapshot & { verifiedOfferRef?: string }
|
|
214
|
+
workspace.loadedOffers = workspace.loadedOffers.map((offer) => offer.offerVersionRef ? offer : { ...offer, offerVersionRef: legacyOfferVersionRef(workspace.contextRef, offer as unknown as Record<string, unknown>) })
|
|
215
|
+
delete workspace.verifiedOfferRef
|
|
216
|
+
return workspace
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveReplayOfferVersion(offerRef: string, workspace?: BookingWorkspaceSnapshot, action?: BookingActionCheckpoint, actionId?: string, priorAvailability?: AvailabilityPolicyState): string {
|
|
220
|
+
const priorAttempt = actionId ? priorAvailability?.attempts.find((attempt) => attempt.actionId === actionId) : undefined
|
|
221
|
+
if (priorAttempt?.offerVersionRef) return priorAttempt.offerVersionRef
|
|
222
|
+
if (action?.kind === 'offer.check' && action.actionId === actionId && action.input.offerRef === offerRef && typeof action.input.offerVersionRef === 'string') return action.input.offerVersionRef
|
|
223
|
+
const match = workspace?.loadedOffers.find((offer) => offer.offerRef === offerRef)
|
|
224
|
+
if (match?.offerVersionRef) return match.offerVersionRef
|
|
225
|
+
throw new Error('booking_replan_required:offer_version_unbound')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeAvailabilityPolicyForReplay(raw: AvailabilityPolicyState, workspace?: BookingWorkspaceSnapshot, action?: BookingActionCheckpoint, priorAvailability?: AvailabilityPolicyState, workspaceDigestMap = new Map<string, string>(), receipt?: ActionReceipt): AvailabilityPolicyState {
|
|
229
|
+
const normalized = structuredClone(raw) as AvailabilityPolicyState
|
|
230
|
+
if (normalized.hotels && typeof normalized.hotels === 'object') {
|
|
231
|
+
for (const [hotelRef, hotel] of Object.entries(normalized.hotels) as Array<[string, AvailabilityPolicyState['hotels'][string]]>) {
|
|
232
|
+
const rawHotel = (raw.hotels as Record<string, unknown> | undefined)?.[hotelRef] as Record<string, unknown> | undefined
|
|
233
|
+
const priorHotel = priorAvailability?.hotels[hotelRef]
|
|
234
|
+
if (!Array.isArray(hotel.tombstonedOfferRefs)) hotel.tombstonedOfferRefs = priorHotel ? [...priorHotel.tombstonedOfferRefs] : []
|
|
235
|
+
if (!Array.isArray(hotel.tombstonedOfferVersionRefs)) hotel.tombstonedOfferVersionRefs = priorHotel ? [...priorHotel.tombstonedOfferVersionRefs] : []
|
|
236
|
+
if (rawHotel && Array.isArray(rawHotel.tombstonedOfferRefs) && priorHotel) {
|
|
237
|
+
for (const ref of priorHotel.tombstonedOfferRefs) if (!hotel.tombstonedOfferRefs.includes(ref)) throw new Error('booking_replan_required:tombstone_regression')
|
|
238
|
+
}
|
|
239
|
+
if (rawHotel && Array.isArray(rawHotel.tombstonedOfferVersionRefs) && priorHotel) {
|
|
240
|
+
for (const ref of priorHotel.tombstonedOfferVersionRefs) if (!hotel.tombstonedOfferVersionRefs.includes(ref)) throw new Error('booking_replan_required:tombstone_regression')
|
|
241
|
+
}
|
|
242
|
+
const source = hotel.currentGeneration?.source
|
|
243
|
+
if (source?.kind === 'workspace_snapshot') {
|
|
244
|
+
const mappedDigest = workspaceDigestMap.get(source.workspaceDigest)
|
|
245
|
+
if (mappedDigest) source.workspaceDigest = mappedDigest
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (Array.isArray(normalized.attempts)) {
|
|
250
|
+
normalized.attempts = normalized.attempts.map((attempt) => attempt.offerVersionRef ? attempt : { ...attempt, offerVersionRef: resolveReplayOfferVersion(attempt.offerRef, workspace, action, attempt.actionId, priorAvailability) })
|
|
251
|
+
}
|
|
252
|
+
const availability = receipt?.observation.kind === 'offer.availability' ? receipt.observation : undefined
|
|
253
|
+
if (receipt && action?.kind === 'offer.check' && availability && availability.offerRef === action.input.offerRef && availability.checkedOfferVersionRef === action.input.offerVersionRef) {
|
|
254
|
+
const attempt = normalized.attempts.find((candidate) => candidate.actionId === action.actionId)
|
|
255
|
+
const hotel = attempt ? normalized.hotels[attempt.hotelRef] : undefined
|
|
256
|
+
if (hotel) {
|
|
257
|
+
if (receipt.status === 'changed' && availability.available === true && availability.currentOfferVersionRef && availability.currentOfferVersionRef !== action.input.offerVersionRef) {
|
|
258
|
+
if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef)) hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef)
|
|
259
|
+
}
|
|
260
|
+
const completeNegative = (receipt.status === 'unavailable' || receipt.status === 'no_match')
|
|
261
|
+
&& receipt.resultContract.outcome === 'empty'
|
|
262
|
+
&& receipt.resultContract.gapCodes.length === 0
|
|
263
|
+
&& receipt.resultContract.blockers.length === 0
|
|
264
|
+
&& availability.available === false
|
|
265
|
+
&& !availability.currentOfferVersionRef
|
|
266
|
+
&& !availability.verifiedOfferRef
|
|
267
|
+
&& !availability.gapCodes?.length
|
|
268
|
+
if (completeNegative) {
|
|
269
|
+
if (!hotel.tombstonedOfferVersionRefs.includes(action.input.offerVersionRef)) hotel.tombstonedOfferVersionRefs.push(action.input.offerVersionRef)
|
|
270
|
+
if (!hotel.tombstonedOfferRefs.includes(action.input.offerRef)) hotel.tombstonedOfferRefs.push(action.input.offerRef)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return normalized
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function normalizeActionCheckpointForReplay(raw: BookingActionCheckpoint, workspace?: BookingWorkspaceSnapshot): BookingActionCheckpoint {
|
|
278
|
+
if (!['offer.select', 'offer.check', 'checkout.prepare'].includes(raw.kind) || typeof raw.input.offerVersionRef === 'string') return raw
|
|
279
|
+
if (typeof raw.input.offerRef !== 'string') throw new Error('booking_replan_required:offer_version_unbound')
|
|
280
|
+
const offerVersionRef = resolveReplayOfferVersion(raw.input.offerRef, workspace, undefined, raw.actionId)
|
|
281
|
+
const input: Record<string, any> = { ...raw.input, offerVersionRef }
|
|
282
|
+
if (raw.kind === 'checkout.prepare' && (!workspace?.verifiedOffer || workspace.verifiedOffer.offerRef !== input.offerRef || workspace.verifiedOffer.offerVersionRef !== offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== input.verifiedOfferRef)) throw new Error('booking_replan_required:legacy_checkout_unverified')
|
|
283
|
+
const { actionDigest: _rawActionDigest, ...rawBase } = raw
|
|
284
|
+
const normalizedBase: Omit<BookingActionCheckpoint, 'actionDigest'> = { ...rawBase, input, inputDigest: bookingDigest(input) }
|
|
285
|
+
return { ...normalizedBase, actionDigest: checkpointDigest(normalizedBase) }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function checkpointDigestMatchesForReplay(raw: BookingActionCheckpoint, normalized: BookingActionCheckpoint): boolean {
|
|
289
|
+
const { actionDigest: _rawDigest, ...rawBase } = raw
|
|
290
|
+
const { actionDigest: _normalizedDigest, ...normalizedBase } = normalized
|
|
291
|
+
return raw.actionDigest === checkpointDigest(rawBase) || raw.actionDigest === checkpointDigest(normalizedBase)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function replanAfterReplayUpgradeGap(state: BookingCopilotTaskState, workspace = state.workspaceSnapshot, revision = workspace?.revision): BookingCopilotTaskState {
|
|
295
|
+
if (!workspace) return { ...state, phase: 'error', pendingAction: undefined }
|
|
296
|
+
const safeRevision = typeof revision === 'number' && Number.isSafeInteger(revision) ? revision : state.revision
|
|
297
|
+
return {
|
|
298
|
+
...state,
|
|
299
|
+
revision: safeRevision,
|
|
300
|
+
phase: 'planning',
|
|
301
|
+
pendingAction: undefined,
|
|
302
|
+
awaitingApproval: undefined,
|
|
303
|
+
workspaceDigest: bookingWorkspaceDigest(workspace),
|
|
304
|
+
workspaceSemanticDigest: bookingWorkspaceSemanticDigest(workspace),
|
|
305
|
+
workspaceSnapshot: workspace,
|
|
306
|
+
availability: createAvailabilityPolicy(workspace),
|
|
307
|
+
replayUpgradeRequired: true,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function assertReplayUpgradeReanchored(state: Pick<BookingCopilotTaskState, 'replayUpgradeRequired'>): void {
|
|
312
|
+
if (state.replayUpgradeRequired) throw new Error('reanchor_turn_required')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function consumedIdentityKey(eventId: string, sequence: number): string { return `${eventId}\0${sequence}` }
|
|
316
|
+
|
|
317
|
+
function clearPendingBatch(consumed: ReplayConsumedIdentities): void {
|
|
318
|
+
consumed.pendingBatchKeys.clear()
|
|
319
|
+
consumed.pendingBatchOpen = false
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function rememberPendingBatchKey(consumed: ReplayConsumedIdentities, key: string): void {
|
|
323
|
+
consumed.pendingBatchKeys.add(key)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function rememberConsumedEventIdentity(consumed: ReplayConsumedIdentities, payload: EventPayload): void {
|
|
327
|
+
if (payload.eventKind === 'status' && (payload.status === 'submitted' || payload.status === 'working') && !consumed.pendingBatchOpen) {
|
|
328
|
+
clearPendingBatch(consumed)
|
|
329
|
+
consumed.pendingBatchOpen = true
|
|
330
|
+
}
|
|
331
|
+
const key = consumedIdentityKey(payload.eventId, payload.sequence)
|
|
332
|
+
consumed.events.set(key, payload)
|
|
333
|
+
rememberPendingBatchKey(consumed, key)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function rememberConsumedActionIdentity(consumed: ReplayConsumedIdentities, action: BookingActionCheckpoint): void {
|
|
337
|
+
const key = consumedIdentityKey(action.eventId, action.sequence)
|
|
338
|
+
consumed.actions.set(key, action)
|
|
339
|
+
rememberPendingBatchKey(consumed, key)
|
|
124
340
|
}
|
|
125
341
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
342
|
+
function consumeSkippedActionOrdinal(state: BookingCopilotTaskState, payload: ActionPayload, consumed?: ReplayConsumedIdentities): void {
|
|
343
|
+
const action = payload.action
|
|
344
|
+
const operationCount = payload.operationCount ?? state.operationCount + 1
|
|
345
|
+
if (!action || typeof action !== 'object') throw new Error('skip_action_invalid')
|
|
346
|
+
const { actionDigest: _actionDigest, ...actionBase } = action
|
|
347
|
+
if (action.actionDigest !== checkpointDigest(actionBase) || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !Number.isSafeInteger(action.sequence) || action.sequence <= state.lastSequence) throw new Error('skip_action_invalid')
|
|
348
|
+
state.operationCount = operationCount
|
|
349
|
+
state.lastSequence = action.sequence
|
|
350
|
+
if (consumed) {
|
|
351
|
+
rememberConsumedActionIdentity(consumed, action)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function eventDraftDigest(event: Exclude<BookingSurfaceEvent, { kind: 'operation' }>): string {
|
|
356
|
+
if (event.kind === 'status') return bookingDigest({ kind: event.kind, status: event.status })
|
|
357
|
+
if (event.kind === 'question') return bookingDigest({ kind: event.kind, question: event.question })
|
|
358
|
+
if (event.kind === 'explanation') return bookingDigest({ kind: event.kind, explanation: event.explanation })
|
|
359
|
+
if (event.kind === 'terminal') return bookingDigest({ kind: event.kind, terminal: event.terminal })
|
|
360
|
+
return bookingDigest({ kind: event.kind, error: event.error })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function eventPayloadMatchesEvent(payload: EventPayload, event: Exclude<BookingSurfaceEvent, { kind: 'operation' }>): boolean {
|
|
364
|
+
const expectedDigest = eventDraftDigest(event)
|
|
365
|
+
const legacyTerminalDigest = event.kind === 'terminal' ? bookingDigest(event.terminal) : undefined
|
|
366
|
+
return payload.eventId === event.eventId
|
|
367
|
+
&& payload.sequence === event.sequence
|
|
368
|
+
&& payload.emittedAt === event.emittedAt
|
|
369
|
+
&& payload.contextRef === event.contextRef
|
|
370
|
+
&& payload.eventKind === event.kind
|
|
371
|
+
&& (event.kind !== 'status' || payload.status === event.status)
|
|
372
|
+
&& (payload.contentDigest === expectedDigest || payload.contentDigest === legacyTerminalDigest)
|
|
130
373
|
}
|
|
131
374
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
if (typeof
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return target
|
|
375
|
+
function actionFromOperationEventForReplay(event: Extract<BookingSurfaceEvent, { kind: 'operation' }>, checkpoint: BookingActionCheckpoint): BookingReadAction {
|
|
376
|
+
const action = event.action
|
|
377
|
+
if ((action.kind === 'offer.select' || action.kind === 'offer.check' || action.kind === 'checkout.prepare') && typeof action.input.offerRef === 'string' && typeof action.input.offerVersionRef !== 'string' && typeof checkpoint.input.offerVersionRef === 'string') {
|
|
378
|
+
return { ...action, input: { ...action.input, offerVersionRef: checkpoint.input.offerVersionRef } } as BookingReadAction
|
|
379
|
+
}
|
|
380
|
+
return action
|
|
139
381
|
}
|
|
140
382
|
|
|
141
|
-
function
|
|
142
|
-
return
|
|
383
|
+
function operationEventMatchesAction(event: Extract<BookingSurfaceEvent, { kind: 'operation' }>, checkpoint: BookingActionCheckpoint): boolean {
|
|
384
|
+
return event.eventId === checkpoint.eventId
|
|
385
|
+
&& event.sequence === checkpoint.sequence
|
|
386
|
+
&& event.emittedAt === checkpoint.emittedAt
|
|
387
|
+
&& event.contextRef === checkpoint.contextRef
|
|
388
|
+
&& matchesCheckpoint(actionFromOperationEventForReplay(event, checkpoint), checkpoint)
|
|
143
389
|
}
|
|
144
390
|
|
|
145
|
-
function
|
|
146
|
-
|
|
391
|
+
function normalizeDecisionBatchEnvelopeForReplay(payload: DecisionBatchPayload, consumed: ReplayConsumedIdentities, requireSchemaValid = false): BookingSurfaceEvent[] {
|
|
392
|
+
if (typeof payload.requestKey !== 'string' || !payload.requestKey || !payload.events.length) throw new Error('skip_decision_batch_invalid')
|
|
393
|
+
let lastBatchSequence = -1
|
|
394
|
+
const seen = new Set<string>()
|
|
395
|
+
const normalizedEvents: BookingSurfaceEvent[] = []
|
|
396
|
+
for (const event of payload.events) {
|
|
397
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) throw new Error('skip_decision_batch_invalid')
|
|
398
|
+
lastBatchSequence = event.sequence
|
|
399
|
+
const key = consumedIdentityKey(event.eventId, event.sequence)
|
|
400
|
+
if (seen.has(key)) throw new Error('skip_decision_batch_invalid')
|
|
401
|
+
seen.add(key)
|
|
402
|
+
if (event.kind === 'operation') {
|
|
403
|
+
const checkpoint = consumed.actions.get(key)
|
|
404
|
+
if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) throw new Error('skip_decision_batch_invalid')
|
|
405
|
+
const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) } as BookingSurfaceEvent
|
|
406
|
+
if (requireSchemaValid && !validateBookingSurfaceEvent(normalized).ok) throw new Error('skip_decision_batch_invalid')
|
|
407
|
+
normalizedEvents.push(normalized)
|
|
408
|
+
} else {
|
|
409
|
+
const recorded = consumed.events.get(key)
|
|
410
|
+
if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event)) throw new Error('skip_decision_batch_invalid')
|
|
411
|
+
normalizedEvents.push(event)
|
|
412
|
+
}
|
|
413
|
+
if (!consumed.pendingBatchKeys.has(key)) throw new Error('skip_decision_batch_invalid')
|
|
414
|
+
}
|
|
415
|
+
if (seen.size !== consumed.pendingBatchKeys.size) throw new Error('skip_decision_batch_invalid')
|
|
416
|
+
for (const key of consumed.pendingBatchKeys) if (!seen.has(key)) throw new Error('skip_decision_batch_invalid')
|
|
417
|
+
clearPendingBatch(consumed)
|
|
418
|
+
return normalizedEvents
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function consumeSkippedEventSequence(state: BookingCopilotTaskState, payload: EventPayload | DecisionBatchPayload, consumed: ReplayConsumedIdentities): void {
|
|
422
|
+
if (!payload || typeof payload !== 'object') throw new Error('skip_event_invalid')
|
|
423
|
+
if ('sequence' in payload) {
|
|
424
|
+
const eventPayload = payload as EventPayload
|
|
425
|
+
if (typeof eventPayload.eventId !== 'string' || !eventPayload.eventId || !['status', 'question', 'explanation', 'terminal', 'error'].includes(String(eventPayload.eventKind)) || typeof eventPayload.contentDigest !== 'string' || !/^[0-9a-f]{64}$/.test(eventPayload.contentDigest) || typeof eventPayload.sequence !== 'number' || !Number.isSafeInteger(eventPayload.sequence) || eventPayload.sequence <= state.lastSequence) throw new Error('skip_event_invalid')
|
|
426
|
+
state.lastSequence = eventPayload.sequence
|
|
427
|
+
rememberConsumedEventIdentity(consumed, eventPayload)
|
|
428
|
+
} else if ('events' in payload && Array.isArray(payload.events)) {
|
|
429
|
+
normalizeDecisionBatchEnvelopeForReplay(payload, consumed)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function normalizeReceiptForReplay(raw: ActionReceipt, action: BookingActionCheckpoint, workspace: BookingWorkspaceSnapshot): ActionReceipt {
|
|
434
|
+
const receipt = structuredClone(raw) as ActionReceipt
|
|
435
|
+
if (action.kind === 'offer.select' && receipt.observation.kind === 'offer.selection' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
|
|
436
|
+
receipt.observation.offerVersionRef = action.input.offerVersionRef
|
|
437
|
+
}
|
|
438
|
+
if (action.kind === 'checkout.prepare' && receipt.observation.kind === 'checkout.handoff' && receipt.observation.offerRef === action.input.offerRef && !receipt.observation.offerVersionRef) {
|
|
439
|
+
if (!workspace.verifiedOffer || workspace.verifiedOffer.offerRef !== action.input.offerRef || workspace.verifiedOffer.offerVersionRef !== action.input.offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== action.input.verifiedOfferRef) throw new Error('booking_replan_required:legacy_checkout_unverified')
|
|
440
|
+
receipt.observation.offerVersionRef = action.input.offerVersionRef
|
|
441
|
+
}
|
|
442
|
+
if (action.kind === 'offer.check' && receipt.observation.kind === 'offer.availability' && receipt.observation.offerRef === action.input.offerRef) {
|
|
443
|
+
if (!receipt.observation.checkedOfferVersionRef) receipt.observation.checkedOfferVersionRef = action.input.offerVersionRef
|
|
444
|
+
if (receipt.status === 'applied' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) receipt.observation.currentOfferVersionRef = action.input.offerVersionRef
|
|
445
|
+
if (receipt.status === 'changed' && receipt.observation.available === true && !receipt.observation.currentOfferVersionRef) {
|
|
446
|
+
const current = workspace.loadedOffers.find((offer) => offer.offerRef === action.input.offerRef && offer.offerVersionRef !== action.input.offerVersionRef)
|
|
447
|
+
if (!current) throw new Error('booking_replan_required:changed_version_unbound')
|
|
448
|
+
receipt.observation.currentOfferVersionRef = current.offerVersionRef
|
|
449
|
+
}
|
|
450
|
+
if (receipt.status === 'applied' && receipt.observation.verifiedOfferRef && (!workspace.verifiedOffer || workspace.verifiedOffer.offerRef !== action.input.offerRef || workspace.verifiedOffer.offerVersionRef !== action.input.offerVersionRef || workspace.verifiedOffer.verifiedOfferRef !== receipt.observation.verifiedOfferRef)) throw new Error('booking_replan_required:legacy_verified_scalar')
|
|
451
|
+
}
|
|
452
|
+
if (receipt.resultContract.blockers.length && bookingDigest(raw) !== bookingDigest(receipt)) throw new Error('booking_replan_required:legacy_blocker_digest')
|
|
453
|
+
return receipt
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function errorText(result: { ok: true } | { ok: false; errors: string[] }): string { return result.ok ? '' : result.errors.join('; ') }
|
|
457
|
+
function assertTaskId(taskId: string): void { if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(taskId)) throw new Error('invalid_task_id') }
|
|
458
|
+
function assertSafeRef(value: string): void { if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value)) throw new Error('unsafe_opaque_ref') }
|
|
459
|
+
function safeIdentity(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value) }
|
|
460
|
+
function sameActions(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && [...a].sort().every((x, i) => x === [...b].sort()[i]) }
|
|
461
|
+
function availabilityPolicyTerminal(state: AvailabilityPolicyState): AvailabilityExhaustion | undefined { return availabilityPolicyResult(state) }
|
|
462
|
+
function taskHasTerminalBudget(state: Pick<BookingCopilotTaskState, 'operationCount' | 'availability'>): boolean {
|
|
463
|
+
return state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS || Boolean(state.availability.terminal)
|
|
464
|
+
}
|
|
465
|
+
function assertRequestKey(requestKey: string): asserts requestKey is BookingRequestKey {
|
|
466
|
+
if (!isSafeBookingRequestKey(requestKey)) throw new Error('unsafe_request_key')
|
|
467
|
+
}
|
|
468
|
+
/** Decision batches are runtime-owned and may contain long task/turn/action refs. */
|
|
469
|
+
function assertInternalDecisionKey(requestKey: string): asserts requestKey is BookingInternalDecisionKey {
|
|
470
|
+
if (typeof requestKey !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,2047}$/.test(requestKey)) throw new Error('unsafe_internal_decision_key')
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function assertDecisionBatchFinality(decisions: readonly BookingPlannerDecision[]): void {
|
|
474
|
+
let finalSeen = false
|
|
475
|
+
for (const decision of decisions) {
|
|
476
|
+
const isFinal = decision.kind === 'terminal' || decision.kind === 'error'
|
|
477
|
+
if (finalSeen) throw new Error('decision_batch_finality')
|
|
478
|
+
if (isFinal) finalSeen = true
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function assertPrincipal(principal: BookingIngressPrincipal): void {
|
|
482
|
+
if (!principal || typeof principal.subject !== 'string' || !principal.subject || principal.subject.length > 256 || typeof principal.scope !== 'string' || !principal.scope || principal.scope.length > 256) throw new Error('trusted_principal_required')
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function assertAvailabilityLiveness(state: BookingCopilotTaskState, decisions: readonly BookingPlannerDecision[]): void {
|
|
486
|
+
if (!state.availability.recoveryStarted || state.awaitingApproval) return
|
|
487
|
+
const operations = decisions.filter((decision): decision is Extract<BookingPlannerDecision, { kind: 'operation' }> => decision.kind === 'operation')
|
|
488
|
+
if (operations.length !== 1 || !state.workspaceSnapshot) throw new Error('availability_operation_required')
|
|
489
|
+
const action = operations[0].action
|
|
490
|
+
const activeHotelRef = state.availability.hotelRefs[state.availability.activeHotelOrdinal]
|
|
491
|
+
if (!activeHotelRef) throw new Error('availability_active_hotel_missing')
|
|
492
|
+
if (state.availability.availabilityPhase === 'need_offers') {
|
|
493
|
+
if (action.kind !== 'offers.query' || action.input.hotelRefs.length !== 1 || action.input.hotelRefs[0] !== activeHotelRef) throw new Error('availability_operation_incompatible')
|
|
494
|
+
} else if (state.availability.availabilityPhase === 'need_check') {
|
|
495
|
+
if (action.kind !== 'offer.check' || action.input.offerRef === undefined || action.input.offerVersionRef === undefined || !state.availability.hotels[activeHotelRef]?.currentOfferRefs.includes(action.input.offerRef) || !canIssueOfferCheck(state.availability, state.workspaceSnapshot, action.input.offerRef, action.input.offerVersionRef).ok) throw new Error('availability_operation_incompatible')
|
|
496
|
+
} else {
|
|
497
|
+
throw new Error('availability_operation_incompatible')
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function checkpointDigest(action: Omit<BookingActionCheckpoint, 'actionDigest'>): string {
|
|
502
|
+
return bookingDigest(action)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function checkpointFor(action: BookingReadAction, eventId: string, sequence: number, emittedAt: string, sourceTurnId: string): BookingActionCheckpoint {
|
|
506
|
+
const base: Omit<BookingActionCheckpoint, 'actionDigest'> = {
|
|
147
507
|
actionId: action.actionId,
|
|
148
508
|
kind: action.kind,
|
|
149
509
|
contextRef: action.contextRef,
|
|
150
510
|
expectedRevision: action.expectedRevision,
|
|
151
|
-
factRefs: action.factRefs,
|
|
152
|
-
reasonDigest: action.
|
|
153
|
-
inputDigest: action.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
511
|
+
factRefs: [...action.factRefs],
|
|
512
|
+
reasonDigest: bookingDigest(action.reason),
|
|
513
|
+
inputDigest: bookingDigest(action.input),
|
|
514
|
+
input: action.input,
|
|
515
|
+
eventId,
|
|
516
|
+
sequence,
|
|
517
|
+
emittedAt,
|
|
518
|
+
sourceTurnId,
|
|
519
|
+
...(action.relaxationApprovalRef ? { relaxationApprovalRef: action.relaxationApprovalRef } : {}),
|
|
520
|
+
}
|
|
521
|
+
return { ...base, actionDigest: checkpointDigest(base) }
|
|
158
522
|
}
|
|
159
523
|
|
|
160
|
-
function
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
524
|
+
function matchesCheckpoint(action: BookingReadAction, checkpoint: BookingActionCheckpoint): boolean {
|
|
525
|
+
const candidate = checkpointFor(
|
|
526
|
+
action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef },
|
|
527
|
+
checkpoint.eventId,
|
|
528
|
+
checkpoint.sequence,
|
|
529
|
+
checkpoint.emittedAt,
|
|
530
|
+
checkpoint.sourceTurnId,
|
|
531
|
+
)
|
|
532
|
+
const { actionDigest: _ignored, ...candidateBase } = candidate
|
|
533
|
+
return checkpoint.actionDigest === checkpointDigest(candidateBase)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function receiptSourceDigest(receipt: ActionReceipt): string {
|
|
537
|
+
return bookingDigest({
|
|
538
|
+
...receipt,
|
|
539
|
+
resultContract: {
|
|
540
|
+
...receipt.resultContract,
|
|
541
|
+
blockers: receipt.resultContract.blockers.map(({ sourceReceiptDigest: _ignored, ...blocker }) => blocker),
|
|
542
|
+
},
|
|
172
543
|
})
|
|
173
544
|
}
|
|
174
545
|
|
|
175
|
-
function
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
|
|
546
|
+
function assertCanonicalReceipt(receipt: ActionReceipt): void {
|
|
547
|
+
const expected = receiptSourceDigest(receipt)
|
|
548
|
+
if (receipt.resultContract.blockers.some((blocker) => blocker.sourceReceiptDigest !== expected)) throw new Error('receipt_source_digest_mismatch')
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function receiptObservationMatchesAction(observation: ActionReceipt['observation']['kind'], action: BookingReadActionKind): boolean {
|
|
552
|
+
if (observation === 'gap') return true
|
|
553
|
+
const expected: Partial<Record<BookingReadActionKind, ActionReceipt['observation']['kind']>> = {
|
|
554
|
+
'search.patch': 'search.state', 'search.run': 'search.state', 'results.view.patch': 'results.state',
|
|
555
|
+
'hotel.focus': 'hotel.focus', 'hotel.select': 'hotel.selection', 'offers.query': 'offers.state',
|
|
556
|
+
'offers.view.patch': 'offers.state', 'offers.compare': 'offers.state', 'offer.select': 'offer.selection',
|
|
557
|
+
'offer.check': 'offer.availability', 'checkout.prepare': 'checkout.handoff', 'order.observe': 'order.state',
|
|
558
|
+
}
|
|
559
|
+
return expected[action] === observation
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function sameRefSet(a: readonly string[], b: readonly string[]): boolean {
|
|
563
|
+
return a.length === b.length && [...a].sort().every((value, index) => value === [...b].sort()[index])
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function receiptTargetMatchesAction(receipt: ActionReceipt, action: { kind: BookingReadActionKind; input: Record<string, any> }): boolean {
|
|
567
|
+
const observation = receipt.observation
|
|
568
|
+
const input = action.input
|
|
569
|
+
if (action.kind === 'hotel.focus' && observation.kind === 'hotel.focus') return observation.hotelRef === input.hotelRef
|
|
570
|
+
if (action.kind === 'hotel.select' && observation.kind === 'hotel.selection') return observation.hotelRef === input.hotelRef
|
|
571
|
+
if (action.kind === 'offer.select' && observation.kind === 'offer.selection') return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef
|
|
572
|
+
if (action.kind === 'offer.check' && observation.kind === 'offer.availability') return observation.offerRef === input.offerRef && observation.checkedOfferVersionRef === input.offerVersionRef
|
|
573
|
+
if (action.kind === 'checkout.prepare' && observation.kind === 'checkout.handoff') return observation.offerRef === input.offerRef && observation.offerVersionRef === input.offerVersionRef && observation.verifiedOfferRef === input.verifiedOfferRef
|
|
574
|
+
if (action.kind === 'offers.query' && observation.kind === 'offers.state') return sameRefSet(observation.hotelRefs, input.hotelRefs)
|
|
575
|
+
if (action.kind === 'offers.view.patch' && observation.kind === 'offers.state') return observation.hotelRefs.includes(input.hotelRef)
|
|
576
|
+
if (action.kind === 'offers.compare' && observation.kind === 'offers.state') return input.offerRefs.every((offerRef: string) => observation.offerRefs.includes(offerRef))
|
|
577
|
+
if (action.kind === 'order.observe' && observation.kind === 'order.state') return observation.orderRef === input.orderRef
|
|
578
|
+
return observation.kind === 'gap' || action.kind === 'search.patch' || action.kind === 'search.run' || action.kind === 'results.view.patch'
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function workspaceBoundaryMatches(a: BookingWorkspaceSnapshot, b: BookingWorkspaceSnapshot): boolean {
|
|
582
|
+
return a.locale === b.locale && a.currency === b.currency && a.surface === b.surface && a.contextRef === b.contextRef && a.capabilities.surface === b.capabilities.surface && sameActions(a.capabilities.allowedActions, b.capabilities.allowedActions)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function workspaceHasOfferVersion(workspace: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string): boolean {
|
|
586
|
+
return workspace.loadedOffers.some((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function verifiedOfferMatches(workspace: BookingWorkspaceSnapshot, input: { offerRef: string; offerVersionRef: string; verifiedOfferRef: string }): boolean {
|
|
590
|
+
return workspace.verifiedOffer?.offerRef === input.offerRef && workspace.verifiedOffer.offerVersionRef === input.offerVersionRef && workspace.verifiedOffer.verifiedOfferRef === input.verifiedOfferRef
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function verifiedOfferUnexpired(workspace: BookingWorkspaceSnapshot, now: string): boolean {
|
|
594
|
+
return Boolean(workspace.verifiedOffer && isBookingDateTime(workspace.verifiedOffer.expiresAt) && Date.parse(workspace.verifiedOffer.expiresAt) > Date.parse(now))
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function actionHitsCurrentOfferVersion(workspace: BookingWorkspaceSnapshot, action: BookingReadAction, now?: string): boolean {
|
|
598
|
+
if (action.kind === 'offer.select' || action.kind === 'offer.check') return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef)
|
|
599
|
+
if (action.kind === 'checkout.prepare') return workspaceHasOfferVersion(workspace, action.input.offerRef, action.input.offerVersionRef) && verifiedOfferMatches(workspace, action.input) && (now === undefined || verifiedOfferUnexpired(workspace, now))
|
|
600
|
+
return true
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function shortlistAfterCheckedOffer(previous: readonly string[], current: readonly string[], checkedOfferRef: string): boolean {
|
|
604
|
+
if (sameActions(previous, current)) return true
|
|
605
|
+
const removedChecked = previous.filter((ref) => ref !== checkedOfferRef)
|
|
606
|
+
return removedChecked.length === previous.length - 1 && sameActions(removedChecked, current)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function loadedOffersUnchanged(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot): boolean {
|
|
610
|
+
return sameActions(previous.loadedOffers.map((offer) => bookingDigest(offer)), current.loadedOffers.map((offer) => bookingDigest(offer)))
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function changedFactRefsMatch(previousFactRefs: readonly string[], currentFactRefs: readonly string[], changedFactRefs: readonly string[]): boolean {
|
|
614
|
+
if (!changedFactRefs.length) return false
|
|
615
|
+
const before = new Set(previousFactRefs)
|
|
616
|
+
const after = new Set(currentFactRefs)
|
|
617
|
+
const diff = [
|
|
618
|
+
...previousFactRefs.filter((ref) => !after.has(ref)),
|
|
619
|
+
...currentFactRefs.filter((ref) => !before.has(ref)),
|
|
620
|
+
]
|
|
621
|
+
return sameActions(diff, changedFactRefs)
|
|
179
622
|
}
|
|
180
623
|
|
|
181
|
-
function
|
|
182
|
-
|
|
624
|
+
function loadedOffersChangedVersionOnly(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, offerRef: string, currentOfferVersionRef: string, changedFactRefs: readonly string[]): boolean {
|
|
625
|
+
if (previous.loadedOffers.length !== current.loadedOffers.length) return false
|
|
626
|
+
const previousChecked = previous.loadedOffers.find((offer) => offer.offerRef === offerRef)
|
|
627
|
+
const currentChecked = current.loadedOffers.find((offer) => offer.offerRef === offerRef)
|
|
628
|
+
if (!previousChecked || !currentChecked || previousChecked.hotelRef !== currentChecked.hotelRef || currentChecked.offerVersionRef !== currentOfferVersionRef || previousChecked.offerVersionRef === currentChecked.offerVersionRef) return false
|
|
629
|
+
if (previousChecked.evidenceLevel !== currentChecked.evidenceLevel) return false
|
|
630
|
+
if (!changedFactRefsMatch(previousChecked.factRefs, currentChecked.factRefs, changedFactRefs)) return false
|
|
631
|
+
const previousOther = previous.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingDigest(offer))
|
|
632
|
+
const currentOther = current.loadedOffers.filter((offer) => offer.offerRef !== offerRef).map((offer) => bookingDigest(offer))
|
|
633
|
+
return sameActions(previousOther, currentOther)
|
|
183
634
|
}
|
|
184
635
|
|
|
185
|
-
function
|
|
186
|
-
|
|
636
|
+
function availabilityReceiptIsComplete(receipt: ActionReceipt): boolean {
|
|
637
|
+
return receipt.resultContract.outcome === 'complete'
|
|
638
|
+
&& receipt.resultContract.hardCriteriaMet
|
|
639
|
+
&& receipt.resultContract.gapCodes.length === 0
|
|
640
|
+
&& receipt.resultContract.blockers.length === 0
|
|
187
641
|
}
|
|
188
642
|
|
|
189
|
-
function
|
|
190
|
-
|
|
643
|
+
function offerCheckPostActionMatches(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: { kind: 'offer.check'; input: { offerRef: string; offerVersionRef: string } }, receipt: ActionReceipt): boolean {
|
|
644
|
+
const checkedOfferRef = action.input.offerRef
|
|
645
|
+
const checkedOfferVersionRef = action.input.offerVersionRef
|
|
646
|
+
const observation = receipt.observation.kind === 'offer.availability' ? receipt.observation : undefined
|
|
647
|
+
const negative = receipt.status === 'unavailable' || receipt.status === 'no_match'
|
|
648
|
+
if (['applied', 'changed', 'unavailable', 'no_match'].includes(receipt.status) && !observation) return false
|
|
649
|
+
if (receipt.status === 'applied' && observation?.available && observation.verifiedOfferRef) {
|
|
650
|
+
if (!availabilityReceiptIsComplete(receipt) || observation.gapCodes?.length) return false
|
|
651
|
+
if (!loadedOffersUnchanged(previous, current)) return false
|
|
652
|
+
if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef !== checkedOfferVersionRef) return false
|
|
653
|
+
if (current.selectedOfferRef !== checkedOfferRef) return false
|
|
654
|
+
if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)) return false
|
|
655
|
+
if (!current.verifiedOffer || current.verifiedOffer.offerRef !== checkedOfferRef || current.verifiedOffer.offerVersionRef !== checkedOfferVersionRef || current.verifiedOffer.verifiedOfferRef !== observation.verifiedOfferRef || Number.isNaN(Date.parse(current.verifiedOffer.expiresAt))) return false
|
|
656
|
+
return true
|
|
657
|
+
}
|
|
658
|
+
if (receipt.status === 'changed' && observation?.available && observation.currentOfferVersionRef) {
|
|
659
|
+
if (observation.checkedOfferVersionRef !== checkedOfferVersionRef || observation.currentOfferVersionRef === checkedOfferVersionRef || observation.verifiedOfferRef || !observation.changedFactRefs.length) return false
|
|
660
|
+
if (!loadedOffersChangedVersionOnly(previous, current, checkedOfferRef, observation.currentOfferVersionRef, observation.changedFactRefs)) return false
|
|
661
|
+
if (current.selectedOfferRef !== checkedOfferRef || current.verifiedOffer !== undefined) return false
|
|
662
|
+
return sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)
|
|
663
|
+
}
|
|
664
|
+
if (!loadedOffersUnchanged(previous, current)) return false
|
|
665
|
+
if (negative) {
|
|
666
|
+
if (current.selectedOfferRef !== undefined || current.verifiedOffer !== undefined) return false
|
|
667
|
+
if (!shortlistAfterCheckedOffer(previous.shortlistedOfferRefs, current.shortlistedOfferRefs, checkedOfferRef)) return false
|
|
668
|
+
return !current.shortlistedOfferRefs.includes(checkedOfferRef)
|
|
669
|
+
}
|
|
670
|
+
if (!sameActions(previous.shortlistedOfferRefs, current.shortlistedOfferRefs)) return false
|
|
671
|
+
if (previous.selectedOfferRef !== current.selectedOfferRef) return false
|
|
672
|
+
if (bookingDigest(previous.verifiedOffer ?? null) !== bookingDigest(current.verifiedOffer ?? null)) return false
|
|
673
|
+
return true
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function offerSelectPostActionMatches(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: { kind: 'offer.select'; input: { offerRef: string; offerVersionRef: string } }, receipt: ActionReceipt): boolean {
|
|
677
|
+
if (receipt.status !== 'applied') return current.selectedOfferRef === previous.selectedOfferRef && loadedOffersUnchanged(previous, current)
|
|
678
|
+
if (receipt.observation.kind !== 'offer.selection' || receipt.observation.offerRef !== action.input.offerRef || receipt.observation.offerVersionRef !== action.input.offerVersionRef) return false
|
|
679
|
+
if (current.selectedOfferRef !== action.input.offerRef) return false
|
|
680
|
+
return loadedOffersUnchanged(previous, current)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function workspacePostActionMatches(previous: BookingWorkspaceSnapshot, current: BookingWorkspaceSnapshot, action: BookingActionCheckpoint, receipt: ActionReceipt): boolean {
|
|
684
|
+
if (!workspaceBoundaryMatches(previous, current)) return false
|
|
685
|
+
const mutable: Partial<Record<BookingReadActionKind, string[]>> = {
|
|
686
|
+
'search.patch': ['searchDraft'], 'search.run': ['results', 'visibleHotels'], 'results.view.patch': ['results'],
|
|
687
|
+
'hotel.focus': ['focusedHotelRef'], 'hotel.select': ['focusedHotelRef'],
|
|
688
|
+
'offers.query': ['loadedOffers'], 'offers.view.patch': ['loadedOffers'],
|
|
689
|
+
'offer.select': ['selectedOfferRef'], 'offer.check': ['selectedOfferRef', 'shortlistedOfferRefs', 'verifiedOffer', 'loadedOffers'],
|
|
690
|
+
}
|
|
691
|
+
if (action.kind === 'offer.select' && !offerSelectPostActionMatches(previous, current, { kind: 'offer.select', input: { offerRef: action.input.offerRef, offerVersionRef: action.input.offerVersionRef } }, receipt)) return false
|
|
692
|
+
if (action.kind === 'offer.check' && !offerCheckPostActionMatches(previous, current, { kind: 'offer.check', input: { offerRef: action.input.offerRef, offerVersionRef: action.input.offerVersionRef } }, receipt)) return false
|
|
693
|
+
const allowed = new Set(['revision', ...(mutable[action.kind] ?? [])])
|
|
694
|
+
const keys = new Set([...Object.keys(previous), ...Object.keys(current)])
|
|
695
|
+
for (const key of keys) {
|
|
696
|
+
if (allowed.has(key)) continue
|
|
697
|
+
if (bookingDigest((previous as unknown as Record<string, unknown>)[key]) !== bookingDigest((current as unknown as Record<string, unknown>)[key])) return false
|
|
698
|
+
}
|
|
699
|
+
return true
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function approvalOption(taskId: string, contextRef: string, sourceTurnId: string, presentationRequestKey: string, blocker: CriterionBlocker, to: RelaxationApproval['to'], deliveryNonce: string): RelaxationApproval {
|
|
703
|
+
const base = {
|
|
704
|
+
taskId, contextRef, sourceTurnId, presentationRequestKey,
|
|
705
|
+
approvalId: `approval-${blocker.blockerId}-${to}`, deliveryNonce, blockerId: blocker.blockerId,
|
|
706
|
+
sourceActionId: blocker.sourceActionId, sourceReceiptDigest: blocker.sourceReceiptDigest,
|
|
707
|
+
scope: blocker.scope, code: blocker.code, criterionPath: blocker.criterionPath,
|
|
708
|
+
valueDigest: blocker.valueDigest, from: 'must' as const, to, approved: true as const,
|
|
709
|
+
}
|
|
710
|
+
return { ...base, optionDigest: bookingDigest(base) }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function approvalOptionDigest(approval: RelaxationApproval): string {
|
|
714
|
+
const { optionDigest: _optionDigest, ...base } = approval
|
|
715
|
+
return bookingDigest(base)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function presentationKey(taskId: string, sourceTurnId: string, actionId: string, receiptDigest: string): string {
|
|
719
|
+
return `approval:${taskId}:${sourceTurnId}:${actionId}:${receiptDigest}`
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
type ApprovalTarget = Pick<BookingReadAction, 'actionId' | 'expectedRevision' | 'kind' | 'contextRef'>
|
|
723
|
+
function assertApprovalRef(ref: RelaxationApprovalRef, state: BookingCopilotTaskState, action: ApprovalTarget): void {
|
|
724
|
+
const approval = state.awaitingApproval?.approval
|
|
725
|
+
const blocker = state.awaitingApproval?.blocker
|
|
726
|
+
if (!approval || !blocker || ref.approvalId !== approval.approvalId || ref.blockerId !== blocker.blockerId || ref.contextRef !== state.contextRef || ref.sourceTurnId !== state.awaitingApproval?.sourceTurnId || ref.presentationRequestKey !== state.awaitingApproval?.presentationRequestKey || ref.sourceActionId !== blocker.sourceActionId || ref.targetActionId !== action.actionId || ref.sourceRevision !== action.expectedRevision || ref.targetActionKind !== action.kind || ref.to !== approval.to || ref.expiresAt !== state.awaitingApproval?.expiresAt || ref.nonce !== state.awaitingApproval?.nonce || ref.sourceReceiptDigest !== blocker.sourceReceiptDigest || ref.scope !== blocker.scope || ref.code !== blocker.code || ref.criterionPath !== blocker.criterionPath || ref.valueDigest !== blocker.valueDigest) throw new Error('ledger_corrupt:approval_ref')
|
|
191
727
|
}
|
|
192
728
|
|
|
193
729
|
export class BookingCopilotTaskRuntime {
|
|
194
730
|
private readonly idFactory: (prefix: string) => string
|
|
731
|
+
private readonly contextRefFactory: () => string
|
|
195
732
|
private readonly now: () => string
|
|
733
|
+
private readonly approvalTtlMs: number
|
|
196
734
|
|
|
197
|
-
constructor(
|
|
198
|
-
private readonly ledger: StateLedger,
|
|
199
|
-
options: BookingCopilotRuntimeOptions = {},
|
|
200
|
-
) {
|
|
735
|
+
constructor(private readonly ledger: StateLedger, options: BookingCopilotRuntimeOptions = {}) {
|
|
201
736
|
this.idFactory = options.idFactory ?? ((prefix) => `${prefix}-${randomUUID()}`)
|
|
737
|
+
this.contextRefFactory = options.contextRefFactory ?? (() => this.idFactory('context'))
|
|
202
738
|
this.now = options.now ?? (() => new Date().toISOString())
|
|
739
|
+
this.approvalTtlMs = options.approvalTtlMs ?? 120_000
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Read-only conflict check used before startTask can append a new turn. */
|
|
743
|
+
assertRequestBinding(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): void {
|
|
744
|
+
assertRequestKey(requestKey)
|
|
745
|
+
assertPrincipal(input.principal)
|
|
746
|
+
if (input.requestKey !== requestKey) throw new Error('request_conflict')
|
|
747
|
+
const existing = this.requestBinding(requestKey)
|
|
748
|
+
if (!existing) return
|
|
749
|
+
const candidate = this.requestBindingPayload(requestKey, turn, input)
|
|
750
|
+
if (existing.requestDigest !== candidate.requestDigest || existing.turnId !== candidate.turnId || existing.workspaceDigest !== candidate.workspaceDigest || existing.workspaceSemanticDigest !== candidate.workspaceSemanticDigest || existing.surface !== candidate.surface || existing.capabilityDigest !== candidate.capabilityDigest || existing.contextRef !== turn.workspace.contextRef || existing.principalDigest !== candidate.principalDigest || existing.scopeDigest !== candidate.scopeDigest || existing.taskHandle !== candidate.taskHandle) throw new Error('request_conflict')
|
|
203
751
|
}
|
|
204
752
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (!validation.ok || turn.kind !== 'user.turn') throw new Error(`invalid_planner_turn:${validationMessage(validation)}`)
|
|
208
|
-
const taskId = turn.taskId ?? this.idFactory('task')
|
|
753
|
+
/** Verifies a direct planner turn against its durable TURN row. */
|
|
754
|
+
assertTurnBinding(taskId: string, turn: UserTurn): void {
|
|
209
755
|
assertTaskId(taskId)
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
756
|
+
const row = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq DESC`).all(this.ledger.tenant, taskId, TURN) as Array<{ payload: string }>
|
|
757
|
+
const observed = row.map(({ payload }) => JSON.parse(payload) as TurnPayload).find((candidate) => candidate.turnId === turn.turnId)
|
|
758
|
+
if (!observed || observed.requestDigest !== bookingTurnDigest(turn) || observed.workspaceDigest !== bookingWorkspaceDigest(turn.workspace) || observed.workspaceSemanticDigest !== bookingWorkspaceSemanticDigest(turn.workspace) || observed.contextRef !== turn.workspace.contextRef) throw new Error('turn_conflict')
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Rejects a canonical approval after its durable runtime consumption. */
|
|
762
|
+
assertApprovalNotConsumed(taskId: string, approval: RelaxationApproval): void {
|
|
763
|
+
assertTaskId(taskId)
|
|
764
|
+
const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, APPROVAL_CONSUMED) as Array<{ payload: string }>
|
|
765
|
+
for (const row of rows) {
|
|
766
|
+
const consumed = JSON.parse(row.payload) as ApprovalPayload
|
|
767
|
+
if (consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval)) throw new Error('approval_replayed')
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Persists the per-requestKey receipt/replay binding after task creation. */
|
|
772
|
+
persistRequestBinding(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): void {
|
|
773
|
+
assertRequestKey(requestKey)
|
|
774
|
+
assertPrincipal(input.principal)
|
|
775
|
+
if (input.requestKey !== requestKey) throw new Error('request_conflict')
|
|
776
|
+
const run = this.ledger.db.transaction(() => {
|
|
777
|
+
const state = this.requireTask(turn.taskId)
|
|
778
|
+
if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
|
|
779
|
+
assertReplayUpgradeReanchored(state)
|
|
780
|
+
this.appendRequestBindingInTransaction(requestKey, turn, input)
|
|
781
|
+
})
|
|
782
|
+
run.immediate()
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Binds the untrusted ingress snapshot to server-owned task/context/capabilities. */
|
|
786
|
+
bindIngressTurn(turn: IngressTurn, task: BookingCopilotTaskState, turnId: string): UserTurn {
|
|
787
|
+
assertSafeRef(turnId)
|
|
788
|
+
const approval = turn.request.approval
|
|
789
|
+
if (approval && (approval.taskId !== task.taskId || approval.contextRef !== task.contextRef)) {
|
|
790
|
+
throw new Error('invalid_ingress_approval_authority')
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
schemaVersion: 'booking.surface', kind: 'user.turn', taskId: task.taskId, turnId,
|
|
794
|
+
workspace: {
|
|
795
|
+
...turn.workspace,
|
|
796
|
+
schemaVersion: 'booking.surface', contextRef: task.contextRef, surface: task.surface,
|
|
797
|
+
capabilities: { surface: task.surface, allowedActions: [...task.allowedActions] },
|
|
798
|
+
},
|
|
799
|
+
request: approval
|
|
800
|
+
? { text: turn.request.text, approval: { ...approval } }
|
|
801
|
+
: { text: turn.request.text },
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
startTask(turn: BookingCopilotTurn, requestBinding?: BookingIngressRequestBindingInput): BookingCopilotTaskState {
|
|
806
|
+
const validation = validateBookingSurface(turn)
|
|
807
|
+
if (!validation.ok) throw new Error(`invalid_planner_turn:${errorText(validation)}`)
|
|
808
|
+
if (turn.kind === 'user.turn.ingress') throw new Error('ingress_binding_required')
|
|
809
|
+
if (turn.kind === 'action.receipt.continuation') throw new Error('receipt_continuation_requires_continue')
|
|
810
|
+
const userTurn = turn as UserTurn
|
|
811
|
+
if (!userTurn.taskId) throw new Error('invalid_planner_turn:task_id_required')
|
|
812
|
+
const taskId = userTurn.taskId
|
|
813
|
+
assertTaskId(taskId)
|
|
814
|
+
const turnId = userTurn.turnId
|
|
815
|
+
if (!turnId) throw new Error('invalid_planner_turn:turn_id_required')
|
|
816
|
+
assertSafeRef(turnId)
|
|
817
|
+
const contextRef = userTurn.workspace.contextRef
|
|
818
|
+
const surface = userTurn.workspace.surface
|
|
819
|
+
const revision = userTurn.workspace.revision
|
|
820
|
+
const allowedActions = [...userTurn.workspace.capabilities.allowedActions]
|
|
821
|
+
const requestDigest = bookingTurnDigest(turn)
|
|
822
|
+
const boundWorkspace = userTurn.workspace
|
|
823
|
+
assertWorkspaceLoadedOfferRefsUnique(boundWorkspace)
|
|
824
|
+
const workspaceDigest = bookingWorkspaceDigest(boundWorkspace)
|
|
825
|
+
const workspaceSemanticDigest = bookingWorkspaceSemanticDigest(boundWorkspace)
|
|
826
|
+
const availability = createAvailabilityPolicy(boundWorkspace)
|
|
827
|
+
const run = this.ledger.db.transaction(() => {
|
|
216
828
|
const existing = this.resumeTask(taskId)
|
|
217
829
|
if (existing) {
|
|
218
|
-
if (existing.contextRef !==
|
|
219
|
-
if (existing.surface !==
|
|
220
|
-
if (existing.revision !==
|
|
221
|
-
if (!
|
|
222
|
-
|
|
830
|
+
if (existing.contextRef !== contextRef) throw new Error('task_conflict:context_mismatch')
|
|
831
|
+
if (existing.surface !== surface) throw new Error('task_conflict:surface_mismatch')
|
|
832
|
+
if (existing.revision !== revision && existing.phase !== 'waiting_receipt' && !existing.replayUpgradeRequired) throw new Error('task_conflict:revision_mismatch')
|
|
833
|
+
if (!sameActions(existing.allowedActions, allowedActions)) throw new Error('task_conflict:capability_mismatch')
|
|
834
|
+
if (existing.phase === 'terminal' || existing.phase === 'error') throw new Error('task_terminal')
|
|
835
|
+
if (turnId && existing.lastTurnId === turnId) {
|
|
836
|
+
this.assertTurnBinding(taskId, userTurn)
|
|
837
|
+
if (requestBinding) {
|
|
838
|
+
assertReplayUpgradeReanchored(existing)
|
|
839
|
+
this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
|
|
840
|
+
}
|
|
841
|
+
return existing
|
|
223
842
|
}
|
|
224
843
|
if (existing.phase === 'waiting_receipt') throw new Error('receipt_required')
|
|
225
|
-
|
|
844
|
+
const approval = userTurn && 'approval' in userTurn.request ? userTurn.request.approval : undefined
|
|
845
|
+
let approvalState: BookingApprovalState | undefined
|
|
846
|
+
if (approval) {
|
|
847
|
+
assertReplayUpgradeReanchored(existing)
|
|
848
|
+
const awaiting = existing.awaitingApproval
|
|
849
|
+
if (!awaiting) throw new Error('approval_not_awaiting')
|
|
850
|
+
if (!awaiting.optionsEmitted) throw new Error('approval_not_presented')
|
|
851
|
+
if (Date.parse(awaiting.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired')
|
|
852
|
+
const checked = validateApprovalAgainstBlocker(approval, awaiting.blocker)
|
|
853
|
+
const option = awaiting.options.find((candidate) => candidate.optionDigest === approval.optionDigest && bookingDigest(candidate) === bookingDigest(approval))
|
|
854
|
+
if (!checked.ok || !option || approval.taskId !== existing.taskId || approval.contextRef !== existing.contextRef || approval.sourceTurnId !== awaiting.sourceTurnId || approval.presentationRequestKey !== awaiting.presentationRequestKey || approval.optionDigest !== approvalOptionDigest(approval) || (awaiting.approval && bookingDigest(approval) !== bookingDigest(awaiting.approval))) throw new Error('approval_mismatch')
|
|
855
|
+
if (awaiting.approval && bookingDigest(awaiting.approval) === bookingDigest(approval)) {
|
|
856
|
+
// The canonical approval may only be advanced by its original
|
|
857
|
+
// turn identity. A fresh key must not join the in-flight planner
|
|
858
|
+
// and receive the operation batch before durable consumption.
|
|
859
|
+
throw new Error('approval_replayed')
|
|
860
|
+
}
|
|
861
|
+
approvalState = { ...awaiting, approval }
|
|
862
|
+
this.appendApproval(taskId, existing.contextRef, approvalState)
|
|
863
|
+
} else if (existing.awaitingApproval) {
|
|
864
|
+
throw new Error('approval_required')
|
|
865
|
+
}
|
|
866
|
+
this.appendTurn(taskId, existing.contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, existing.userTurnCount + 1, turnId, boundWorkspace)
|
|
867
|
+
if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
|
|
226
868
|
return this.requireTask(taskId)
|
|
227
869
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
schema: LEDGER_SCHEMA,
|
|
234
|
-
taskId,
|
|
235
|
-
contextRef: turn.workspace.contextRef,
|
|
236
|
-
surface: turn.workspace.surface,
|
|
237
|
-
revision: turn.workspace.revision,
|
|
238
|
-
allowedActions: [...turn.workspace.capabilities.allowedActions],
|
|
239
|
-
}
|
|
240
|
-
this.appendLedgerEvent(TASK_STARTED, taskId, payload, `booking-copilot:task:${taskId}`)
|
|
241
|
-
this.observeUserTurn(taskId, turn.workspace.contextRef, requestDigest, 1)
|
|
870
|
+
if (userTurn?.request.approval) throw new Error('approval_not_awaiting')
|
|
871
|
+
const started: StartedPayload = { schema: LEDGER_SCHEMA, taskId, contextRef, surface, revision, allowedActions, workspaceDigest, workspaceSemanticDigest, workspace: boundWorkspace, availability, availabilityDigest: bookingDigest(availability) }
|
|
872
|
+
this.append(STARTED, taskId, started, `booking-copilot:task:${taskId}`)
|
|
873
|
+
this.appendTurn(taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, 1, turnId, boundWorkspace)
|
|
874
|
+
if (requestBinding) this.appendRequestBindingInTransaction(requestBinding.requestKey, userTurn, requestBinding)
|
|
242
875
|
return this.requireTask(taskId)
|
|
243
876
|
})
|
|
244
|
-
return
|
|
877
|
+
return run.immediate()
|
|
245
878
|
}
|
|
246
879
|
|
|
247
|
-
resumeTask(taskId: string):
|
|
880
|
+
resumeTask(taskId: string): BookingCopilotTaskState | null {
|
|
248
881
|
assertTaskId(taskId)
|
|
249
|
-
const rows = this.
|
|
250
|
-
if (rows.length
|
|
251
|
-
let state:
|
|
882
|
+
const rows = this.rows(taskId)
|
|
883
|
+
if (!rows.length) return null
|
|
884
|
+
let state: BookingCopilotTaskState | null = null
|
|
885
|
+
let skipLegacyUpgradeTail = false
|
|
886
|
+
const replayWorkspaceDigests = new Map<string, string>()
|
|
887
|
+
const consumed: ReplayConsumedIdentities = { events: new Map(), actions: new Map(), pendingBatchKeys: new Set(), pendingBatchOpen: false }
|
|
888
|
+
const suppressedDecisionRequestKeys = new Set<string>()
|
|
889
|
+
const suppressedRequestBindingKeys = new Set<string>()
|
|
890
|
+
const suppressedReceiptActionIds = new Set<string>()
|
|
891
|
+
const suppressedApprovalTargetActionIds = new Set<string>()
|
|
252
892
|
for (const row of rows) {
|
|
253
|
-
const payload = JSON.parse(row.payload) as Record<string,
|
|
893
|
+
const payload = JSON.parse(row.payload) as BasePayload & Record<string, any>
|
|
254
894
|
if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId) throw new Error(`ledger_corrupt:${taskId}:seq=${row.seq}`)
|
|
255
|
-
if (row.kind ===
|
|
256
|
-
|
|
895
|
+
if (row.kind === STARTED) {
|
|
896
|
+
skipLegacyUpgradeTail = false
|
|
257
897
|
if (state) throw new Error(`ledger_corrupt:${taskId}:duplicate_start`)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
898
|
+
const started = payload as StartedPayload
|
|
899
|
+
let startedWorkspace: BookingWorkspaceSnapshot
|
|
900
|
+
try { startedWorkspace = normalizeWorkspaceForReplay(started.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_workspace`) }
|
|
901
|
+
let initialAvailability: AvailabilityPolicyState
|
|
902
|
+
try { initialAvailability = createAvailabilityPolicy(startedWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_availability`) }
|
|
903
|
+
try { assertWorkspaceLoadedOfferRefsUnique(startedWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:start_workspace`) }
|
|
904
|
+
rememberReplayWorkspaceDigest(replayWorkspaceDigests, started.workspaceDigest, started.workspace, startedWorkspace)
|
|
905
|
+
const startedAvailability = normalizeAvailabilityPolicyForReplay(started.availability, startedWorkspace, undefined, undefined, replayWorkspaceDigests)
|
|
906
|
+
if (!started.workspaceDigest || !started.workspaceSemanticDigest || !started.workspace || !started.availability || !started.availabilityDigest || !validateAvailabilityPolicy(startedAvailability) || !replayDigestMatches(started.availabilityDigest, started.availability, startedAvailability) || bookingDigest(initialAvailability) !== bookingDigest(startedAvailability) || !replayWorkspaceDigestMatches(started.workspaceDigest, started.workspace, startedWorkspace) || !replayWorkspaceSemanticDigestMatches(started.workspaceSemanticDigest, started.workspace, startedWorkspace)) throw new Error(`ledger_corrupt:${taskId}:start_workspace`)
|
|
907
|
+
state = { schemaVersion: 'booking.surface', taskId, contextRef: started.contextRef, surface: started.surface, revision: started.revision, allowedActions: [...started.allowedActions], userTurnCount: 0, operationCount: 0, phase: startedAvailability.terminal ? 'terminal' : 'planning', lastSequence: 0, workspaceDigest: bookingWorkspaceDigest(startedWorkspace), workspaceSemanticDigest: bookingWorkspaceSemanticDigest(startedWorkspace), workspaceSnapshot: startedWorkspace, availability: startedAvailability }
|
|
908
|
+
} else {
|
|
909
|
+
if (!state) throw new Error(`ledger_corrupt:${taskId}:event_before_start`)
|
|
910
|
+
if (payload.contextRef !== state.contextRef) throw new Error(`ledger_corrupt:${taskId}:context_drift`)
|
|
911
|
+
if (row.kind === REQUEST_BINDING) {
|
|
912
|
+
if (skipLegacyUpgradeTail) {
|
|
913
|
+
const binding = payload as RequestBindingPayload
|
|
914
|
+
if (typeof binding.requestKey === 'string') suppressedRequestBindingKeys.add(binding.requestKey)
|
|
915
|
+
continue
|
|
916
|
+
}
|
|
917
|
+
const binding = payload as RequestBindingPayload
|
|
918
|
+
const turnRows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, TURN) as Array<{ payload: string }>
|
|
919
|
+
const rawBoundTurn = turnRows.map(({ payload }) => JSON.parse(payload) as TurnPayload).find((candidate) => candidate.turnId === binding.turnId)
|
|
920
|
+
let boundTurn: TurnPayload | undefined
|
|
921
|
+
try { boundTurn = rawBoundTurn ? { ...rawBoundTurn, workspace: normalizeWorkspaceForReplay(rawBoundTurn.workspace) } : undefined } catch { throw new Error(`ledger_corrupt:${taskId}:request_binding`) }
|
|
922
|
+
if (!isSafeBookingRequestKey(binding.requestKey) || !safeIdentity(binding.turnId) || !safeIdentity(binding.contextRef) || (binding.taskHandle !== undefined && !safeIdentity(binding.taskHandle)) || !/^[0-9a-f]{64}$/.test(binding.requestDigest) || !/^[0-9a-f]{64}$/.test(binding.workspaceDigest) || !/^[0-9a-f]{64}$/.test(binding.workspaceSemanticDigest) || !/^[0-9a-f]{64}$/.test(binding.capabilityDigest) || !/^[0-9a-f]{64}$/.test(binding.principalDigest) || !/^[0-9a-f]{64}$/.test(binding.scopeDigest) || !['tenant', 'customer_portal', 'storefront', 'payment_link'].includes(binding.surface) || !boundTurn || boundTurn.requestDigest !== binding.requestDigest || boundTurn.workspaceDigest !== binding.workspaceDigest || boundTurn.workspaceSemanticDigest !== binding.workspaceSemanticDigest || bookingDigest(boundTurn.workspace.capabilities) !== binding.capabilityDigest || boundTurn.workspace.surface !== binding.surface) throw new Error(`ledger_corrupt:${taskId}:request_binding`)
|
|
923
|
+
} else if (row.kind === TURN) {
|
|
924
|
+
skipLegacyUpgradeTail = false
|
|
925
|
+
const t = payload as TurnPayload
|
|
926
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'approval')) throw new Error(`ledger_corrupt:${taskId}:turn_approval`)
|
|
927
|
+
let turnWorkspace: BookingWorkspaceSnapshot
|
|
928
|
+
try { turnWorkspace = normalizeWorkspaceForReplay(t.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:turn_workspace`) }
|
|
929
|
+
try { assertWorkspaceLoadedOfferRefsUnique(turnWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:turn_workspace`) }
|
|
930
|
+
if (!t.workspaceDigest || !t.workspaceSemanticDigest || !t.workspace || !replayWorkspaceDigestMatches(t.workspaceDigest, t.workspace, turnWorkspace) || !replayWorkspaceSemanticDigestMatches(t.workspaceSemanticDigest, t.workspace, turnWorkspace)) throw new Error(`ledger_corrupt:${taskId}:turn_workspace`)
|
|
931
|
+
rememberReplayWorkspaceDigest(replayWorkspaceDigests, t.workspaceDigest, t.workspace, turnWorkspace)
|
|
932
|
+
state.userTurnCount++
|
|
933
|
+
state.lastTurnId = t.turnId
|
|
934
|
+
state.workspaceDigest = bookingWorkspaceDigest(turnWorkspace)
|
|
935
|
+
state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(turnWorkspace)
|
|
936
|
+
state.workspaceSnapshot = turnWorkspace
|
|
937
|
+
state.revision = turnWorkspace.revision
|
|
938
|
+
delete state.replayUpgradeRequired
|
|
939
|
+
// Approval authority is established only by APPROVAL_GRANTED; a turn
|
|
940
|
+
// is merely an observation and cannot restore or mutate approval state.
|
|
941
|
+
} else if (row.kind === APPROVAL_GRANTED) {
|
|
942
|
+
if (skipLegacyUpgradeTail) continue
|
|
943
|
+
const granted = payload as ApprovalPayload
|
|
944
|
+
if (!granted.approval || granted.approvalDigest !== bookingDigest(granted.approval) || !granted.approval.sourceTurnId || !granted.approval.presentationRequestKey || !validateCriterionBlocker(granted.approval.blocker).ok || !granted.approval.options?.length || granted.approval.options.some((option) => !validateApprovalAgainstBlocker(option, granted.approval!.blocker).ok || option.taskId !== taskId || option.contextRef !== state!.contextRef || option.sourceTurnId !== granted.approval!.sourceTurnId || option.presentationRequestKey !== granted.approval!.presentationRequestKey || option.optionDigest !== approvalOptionDigest(option)) || (granted.approval.approval && !granted.approval.options.some((option) => bookingDigest(option) === bookingDigest(granted.approval!.approval!)))) throw new Error(`ledger_corrupt:${taskId}:approval_granted`)
|
|
945
|
+
state.awaitingApproval = granted.approval; state.phase = 'input_required'
|
|
946
|
+
} else if (row.kind === APPROVAL_OFFERED) {
|
|
947
|
+
if (skipLegacyUpgradeTail) continue
|
|
948
|
+
const offered = payload as ApprovalPayload
|
|
949
|
+
if (!offered.approval || offered.approvalDigest !== bookingDigest(offered.approval) || !state.awaitingApproval || bookingDigest(offered.approval) !== bookingDigest(state.awaitingApproval)) throw new Error(`ledger_corrupt:${taskId}:approval_offered`)
|
|
950
|
+
// This is a durable outbox intent only. It is deliberately not
|
|
951
|
+
// evidence that the question entered a replayable output batch:
|
|
952
|
+
// a crash between this row and DECISION_BATCH must still reject an
|
|
953
|
+
// approval until the exact question can be replayed.
|
|
954
|
+
state.phase = 'input_required'
|
|
955
|
+
} else if (row.kind === ACTION) {
|
|
956
|
+
const actionPayload = payload as ActionPayload
|
|
957
|
+
if (skipLegacyUpgradeTail) { try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } continue }
|
|
958
|
+
const rawAction = actionPayload.action
|
|
959
|
+
const operationCount = actionPayload.operationCount ?? state.operationCount + 1
|
|
960
|
+
const { actionDigest: _rawActionDigest, ...rawActionBase } = rawAction
|
|
961
|
+
if (rawAction.actionDigest !== checkpointDigest(rawActionBase) || rawAction.contextRef !== state.contextRef || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !Number.isSafeInteger(rawAction.sequence) || rawAction.sequence <= state.lastSequence) throw new Error(`ledger_corrupt:${taskId}:action`)
|
|
962
|
+
let a: BookingActionCheckpoint
|
|
963
|
+
try { a = normalizeActionCheckpointForReplay(rawAction, state.workspaceSnapshot) } catch (error) {
|
|
964
|
+
if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { state = replanAfterReplayUpgradeGap(state); try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } skipLegacyUpgradeTail = true; continue }
|
|
965
|
+
throw error
|
|
966
|
+
}
|
|
967
|
+
const { actionDigest: _actionDigest, ...actionBase } = a
|
|
968
|
+
if (!actionPayload.availability || actionPayload.availabilityDigest !== bookingDigest(actionPayload.availability)) throw new Error(`ledger_corrupt:${taskId}:action`)
|
|
969
|
+
let actionAvailability: AvailabilityPolicyState
|
|
970
|
+
try { actionAvailability = normalizeAvailabilityPolicyForReplay(actionPayload.availability, state.workspaceSnapshot, a, state.availability, replayWorkspaceDigests) } catch (error) {
|
|
971
|
+
if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { state = replanAfterReplayUpgradeGap(state); try { consumeSkippedActionOrdinal(state, actionPayload, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:action`) } skipLegacyUpgradeTail = true; continue }
|
|
972
|
+
throw error
|
|
973
|
+
}
|
|
974
|
+
if (!checkpointDigestMatchesForReplay(rawAction, a) || a.actionDigest !== checkpointDigest(actionBase) || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount + 1 || operationCount > BOOKING_COPILOT_MAX_OPERATIONS || !validateAvailabilityPolicy(actionAvailability) || !replayDigestMatches(actionPayload.availabilityDigest, actionPayload.availability, actionAvailability) || !state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:action`)
|
|
975
|
+
if (a.relaxationApprovalRef) assertApprovalRef(a.relaxationApprovalRef, state, a)
|
|
976
|
+
if (state.pendingAction) throw new Error(`ledger_corrupt:${taskId}:parallel_actions`)
|
|
977
|
+
let expectedAvailability: AvailabilityPolicyState
|
|
978
|
+
try { expectedAvailability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, a) } catch { throw new Error(`ledger_corrupt:${taskId}:action_transition`) }
|
|
979
|
+
if (bookingDigest(expectedAvailability) !== bookingDigest(actionAvailability)) throw new Error(`ledger_corrupt:${taskId}:action_transition`)
|
|
980
|
+
state.operationCount = operationCount
|
|
981
|
+
state.availability = actionAvailability
|
|
982
|
+
state.pendingAction = a; state.phase = 'waiting_receipt'; state.lastSequence = Math.max(state.lastSequence, a.sequence)
|
|
983
|
+
{
|
|
984
|
+
rememberConsumedActionIdentity(consumed, a)
|
|
985
|
+
}
|
|
986
|
+
delete state.awaitingApproval
|
|
987
|
+
} else if (row.kind === RECEIPT) {
|
|
988
|
+
if (skipLegacyUpgradeTail) {
|
|
989
|
+
const r = payload as ReceiptPayload
|
|
990
|
+
if (typeof r.receipt?.actionId === 'string') suppressedReceiptActionIds.add(r.receipt.actionId)
|
|
991
|
+
continue
|
|
992
|
+
}
|
|
993
|
+
const r = payload as ReceiptPayload
|
|
994
|
+
const operationCount = r.operationCount ?? state.operationCount
|
|
995
|
+
if (!state.pendingAction || state.pendingAction.actionId !== r.receipt.actionId) throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`)
|
|
996
|
+
let receiptWorkspace: BookingWorkspaceSnapshot
|
|
997
|
+
try { receiptWorkspace = normalizeWorkspaceForReplay(r.workspace) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`) }
|
|
998
|
+
try { assertWorkspaceLoadedOfferRefsUnique(receiptWorkspace) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`) }
|
|
999
|
+
if (!r.workspaceDigest || !r.workspaceSemanticDigest || !r.workspace || !replayWorkspaceDigestMatches(r.workspaceDigest, r.workspace, receiptWorkspace) || !replayWorkspaceSemanticDigestMatches(r.workspaceSemanticDigest, r.workspace, receiptWorkspace)) throw new Error(`ledger_corrupt:${taskId}:receipt_workspace`)
|
|
1000
|
+
rememberReplayWorkspaceDigest(replayWorkspaceDigests, r.workspaceDigest, r.workspace, receiptWorkspace)
|
|
1001
|
+
if (!replayDigestMatches(r.receiptDigest, r.receipt, r.receipt) || r.receipt.contextRef !== state.contextRef || r.receipt.actionId !== state.pendingAction.actionId || !Number.isSafeInteger(r.receipt.revision) || r.receipt.revision < state.pendingAction.expectedRevision || !Number.isSafeInteger(operationCount) || operationCount !== state.operationCount) throw new Error(`ledger_corrupt:${taskId}:receipt`)
|
|
1002
|
+
if (!r.availability || r.availabilityDigest !== bookingDigest(r.availability)) throw new Error(`ledger_corrupt:${taskId}:receipt`)
|
|
1003
|
+
let receipt: ActionReceipt
|
|
1004
|
+
try { receipt = normalizeReceiptForReplay(r.receipt, state.pendingAction, receiptWorkspace) } catch (error) {
|
|
1005
|
+
if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { clearPendingBatch(consumed); state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision); skipLegacyUpgradeTail = true; continue }
|
|
1006
|
+
throw error
|
|
1007
|
+
}
|
|
1008
|
+
let receiptAvailability: AvailabilityPolicyState
|
|
1009
|
+
try { receiptAvailability = normalizeAvailabilityPolicyForReplay(r.availability, receiptWorkspace, state.pendingAction, state.availability, replayWorkspaceDigests, receipt) } catch (error) {
|
|
1010
|
+
if (error instanceof Error && error.message.startsWith('booking_replan_required:')) { clearPendingBatch(consumed); state = replanAfterReplayUpgradeGap(state, receiptWorkspace, r.receipt.revision); skipLegacyUpgradeTail = true; continue }
|
|
1011
|
+
throw error
|
|
1012
|
+
}
|
|
1013
|
+
if (!replayDigestMatches(r.receiptDigest, r.receipt, receipt) || !validateBookingSurface(receipt).ok || !r.availability || !r.availabilityDigest || !validateAvailabilityPolicy(receiptAvailability) || !replayDigestMatches(r.availabilityDigest, r.availability, receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt`)
|
|
1014
|
+
if (Boolean(r.availabilityTerminal) !== Boolean(receiptAvailability.terminal) || (r.availabilityTerminal && receiptAvailability.terminal && bookingDigest(r.availabilityTerminal) !== bookingDigest(receiptAvailability.terminal))) throw new Error(`ledger_corrupt:${taskId}:availability_terminal`)
|
|
1015
|
+
assertCanonicalReceipt(receipt)
|
|
1016
|
+
if (!state.workspaceSnapshot || !workspacePostActionMatches(state.workspaceSnapshot, receiptWorkspace, state.pendingAction, receipt) || !receiptObservationMatchesAction(receipt.observation.kind, state.pendingAction.kind) || !receiptTargetMatchesAction(receipt, state.pendingAction)) throw new Error(`ledger_corrupt:${taskId}:receipt_transition`)
|
|
1017
|
+
let expectedAvailability: AvailabilityPolicyState
|
|
1018
|
+
try { expectedAvailability = reduceAvailabilityReceipt(state.availability, receiptWorkspace, receipt, state.pendingAction) } catch { throw new Error(`ledger_corrupt:${taskId}:receipt_transition`) }
|
|
1019
|
+
if (bookingDigest(expectedAvailability) !== bookingDigest(receiptAvailability)) throw new Error(`ledger_corrupt:${taskId}:receipt_transition`)
|
|
1020
|
+
state.lastReceipt = receipt; state.revision = receipt.revision; state.workspaceDigest = bookingWorkspaceDigest(receiptWorkspace); state.workspaceSemanticDigest = bookingWorkspaceSemanticDigest(receiptWorkspace); state.workspaceSnapshot = receiptWorkspace; state.availability = receiptAvailability; delete state.pendingAction; state.phase = receiptAvailability.terminal || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS ? 'terminal' : 'planning'
|
|
1021
|
+
clearPendingBatch(consumed)
|
|
1022
|
+
delete state.awaitingApproval
|
|
1023
|
+
} else if (row.kind === APPROVAL_CONSUMED) {
|
|
1024
|
+
if (skipLegacyUpgradeTail) {
|
|
1025
|
+
const consumed = payload as ApprovalPayload
|
|
1026
|
+
if (typeof consumed.ref?.targetActionId === 'string') suppressedApprovalTargetActionIds.add(consumed.ref.targetActionId)
|
|
1027
|
+
continue
|
|
1028
|
+
}
|
|
1029
|
+
const consumed = payload as ApprovalPayload
|
|
1030
|
+
if (!consumed.ref || consumed.approvalDigest !== bookingDigest(consumed.ref) || !consumed.approval?.approval || !consumed.approval.options?.some((option) => bookingDigest(option) === bookingDigest(consumed.approval!.approval!)) || !state.awaitingApproval || bookingDigest(consumed.approval) !== bookingDigest(state.awaitingApproval) || consumed.ref.approvalId !== consumed.approval.approval.approvalId || consumed.ref.blockerId !== consumed.approval.blocker.blockerId || consumed.ref.contextRef !== state.contextRef || consumed.ref.sourceTurnId !== consumed.approval.sourceTurnId || consumed.ref.presentationRequestKey !== consumed.approval.presentationRequestKey || consumed.ref.sourceActionId !== consumed.approval.blocker.sourceActionId || consumed.ref.targetActionId === '' || consumed.ref.sourceReceiptDigest !== consumed.approval.blocker.sourceReceiptDigest || consumed.ref.nonce !== consumed.approval.nonce || consumed.ref.nonce !== consumed.approval.approval.deliveryNonce || consumed.ref.to !== consumed.approval.approval.to || consumed.ref.scope !== consumed.approval.blocker.scope || consumed.ref.code !== consumed.approval.blocker.code || consumed.ref.criterionPath !== consumed.approval.blocker.criterionPath || consumed.ref.valueDigest !== consumed.approval.blocker.valueDigest) throw new Error(`ledger_corrupt:${taskId}:approval`)
|
|
1031
|
+
} else if (row.kind === DECISION_BATCH) {
|
|
1032
|
+
const batch = payload as DecisionBatchPayload
|
|
1033
|
+
if (skipLegacyUpgradeTail) {
|
|
1034
|
+
try { consumeSkippedEventSequence(state, batch, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:decision_batch`) }
|
|
1035
|
+
if (typeof batch.requestKey === 'string') suppressedDecisionRequestKeys.add(batch.requestKey)
|
|
1036
|
+
continue
|
|
1037
|
+
}
|
|
1038
|
+
if (!batch.requestKey || !Array.isArray(batch.events) || batch.events.some((event) => event.taskId !== taskId)) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
|
|
1039
|
+
let batchEvents: BookingSurfaceEvent[]
|
|
1040
|
+
try { batchEvents = normalizeDecisionBatchEnvelopeForReplay(batch, consumed, true) } catch { throw new Error(`ledger_corrupt:${taskId}:decision_batch`) }
|
|
1041
|
+
for (const event of batchEvents) {
|
|
1042
|
+
if (event.kind !== 'question') continue
|
|
1043
|
+
const awaiting = state.awaitingApproval
|
|
1044
|
+
if (!awaiting || bookingDigest(event.question.blocker) !== bookingDigest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => bookingDigest(option) === bookingDigest(approval)))) throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`)
|
|
1045
|
+
// Only the atomic, durable batch makes the presentation nonce
|
|
1046
|
+
// usable. APPROVAL_OFFERED alone is intentionally insufficient.
|
|
1047
|
+
awaiting.optionsEmitted = true
|
|
1048
|
+
state.phase = 'input_required'
|
|
1049
|
+
}
|
|
1050
|
+
} else if (row.kind === EVENT) {
|
|
1051
|
+
const event = payload as EventPayload
|
|
1052
|
+
if (skipLegacyUpgradeTail) { try { consumeSkippedEventSequence(state, event, consumed) } catch { throw new Error(`ledger_corrupt:${taskId}:event`) } continue }
|
|
1053
|
+
state.lastSequence = Math.max(state.lastSequence, event.sequence)
|
|
1054
|
+
{
|
|
1055
|
+
rememberConsumedEventIdentity(consumed, event)
|
|
1056
|
+
}
|
|
1057
|
+
if (event.eventKind === 'status') state.phase = event.status === 'submitted' ? 'submitted' : event.status === 'working' ? 'working' : event.status === 'waiting_receipt' ? 'waiting_receipt' : 'input_required'
|
|
1058
|
+
else if (event.eventKind === 'question') state.phase = 'input_required'
|
|
1059
|
+
else if (event.eventKind === 'terminal') state.phase = 'terminal'
|
|
1060
|
+
else if (event.eventKind === 'error') state.phase = 'error'
|
|
268
1061
|
}
|
|
269
|
-
continue
|
|
270
|
-
}
|
|
271
|
-
if (!state) throw new Error(`ledger_corrupt:${taskId}:event_before_start`)
|
|
272
|
-
if (payload.contextRef !== state.contextRef) throw new Error(`ledger_corrupt:${taskId}:context_drift`)
|
|
273
|
-
if (row.kind === USER_TURN_OBSERVED) {
|
|
274
|
-
const observed = payload as unknown as UserTurnObservedPayload
|
|
275
|
-
state.userTurnCount += 1
|
|
276
|
-
state.lastUserTurnDigest = observed.requestDigest
|
|
277
|
-
} else if (row.kind === ACTION_ISSUED) {
|
|
278
|
-
const issued = payload as unknown as ActionIssuedPayload
|
|
279
|
-
if (issued.action.actionDigest !== checkpointDigest(issued.action)) throw new Error(`ledger_corrupt:${taskId}:action`)
|
|
280
|
-
if (state.pendingAction) throw new Error(`ledger_corrupt:${taskId}:parallel_actions`)
|
|
281
|
-
state.pendingAction = issued.action
|
|
282
|
-
state.phase = 'waiting_receipt'
|
|
283
|
-
state.lastSequence = Math.max(state.lastSequence, issued.action.sequence)
|
|
284
|
-
} else if (row.kind === RECEIPT_OBSERVED) {
|
|
285
|
-
const observed = payload as unknown as ReceiptObservedPayload
|
|
286
|
-
if (observed.receiptDigest !== digest(observed.receipt) || !validateActionReceiptV1(observed.receipt).ok) {
|
|
287
|
-
throw new Error(`ledger_corrupt:${taskId}:receipt`)
|
|
288
|
-
}
|
|
289
|
-
if (!state.pendingAction || state.pendingAction.actionId !== observed.receipt.actionId) {
|
|
290
|
-
throw new Error(`ledger_corrupt:${taskId}:orphan_receipt`)
|
|
291
|
-
}
|
|
292
|
-
state.lastReceipt = observed.receipt
|
|
293
|
-
state.revision = observed.receipt.revision
|
|
294
|
-
delete state.pendingAction
|
|
295
|
-
state.phase = 'planning'
|
|
296
|
-
} else if (row.kind === EVENT_EMITTED) {
|
|
297
|
-
const emitted = payload as unknown as EventEmittedPayload
|
|
298
|
-
state.lastSequence = Math.max(state.lastSequence, emitted.sequence)
|
|
299
1062
|
}
|
|
300
1063
|
}
|
|
1064
|
+
if (state) {
|
|
1065
|
+
if (suppressedDecisionRequestKeys.size) state.legacySuppressedDecisionRequestKeys = [...suppressedDecisionRequestKeys]
|
|
1066
|
+
if (suppressedRequestBindingKeys.size) state.legacySuppressedRequestBindingKeys = [...suppressedRequestBindingKeys]
|
|
1067
|
+
if (suppressedReceiptActionIds.size) state.legacySuppressedReceiptActionIds = [...suppressedReceiptActionIds]
|
|
1068
|
+
if (suppressedApprovalTargetActionIds.size) state.legacySuppressedApprovalTargetActionIds = [...suppressedApprovalTargetActionIds]
|
|
1069
|
+
}
|
|
301
1070
|
return state
|
|
302
1071
|
}
|
|
303
1072
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
1073
|
+
withReceiptDigest<T extends ActionReceipt>(receipt: T): T {
|
|
1074
|
+
// The source receipt digest is the authority used by the approval binding.
|
|
1075
|
+
const sourceDigest = receiptSourceDigest(receipt)
|
|
1076
|
+
return { ...receipt, resultContract: { ...receipt.resultContract, blockers: receipt.resultContract.blockers.map((b) => ({ ...b, sourceReceiptDigest: sourceDigest })) } }
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
receiptDigest(receipt: ActionReceipt): string { return canonicalReceiptDigest(receipt) }
|
|
1080
|
+
|
|
1081
|
+
/** Builds the only relaxation question exposed to the planner stream. The
|
|
1082
|
+
* blocker is ledger-derived; a planner cannot invent its tuple or ref. */
|
|
1083
|
+
approvalQuestion(taskId: string): BookingSurfaceEventDraft {
|
|
1084
|
+
const task = this.requireTask(taskId)
|
|
1085
|
+
const awaiting = task.awaitingApproval
|
|
1086
|
+
if (!awaiting) throw new Error('approval_not_awaiting')
|
|
1087
|
+
return {
|
|
1088
|
+
kind: 'question',
|
|
1089
|
+
question: {
|
|
1090
|
+
questionId: `question-${awaiting.blocker.blockerId}`,
|
|
1091
|
+
prompt: `Relax required criterion ${awaiting.blocker.criterionPath}?`,
|
|
1092
|
+
missingFields: [awaiting.blocker.criterionPath],
|
|
1093
|
+
type: 'relaxation_approval_required',
|
|
1094
|
+
blocker: awaiting.blocker,
|
|
1095
|
+
approvalOptions: awaiting.options.map((approval) => ({ approval })),
|
|
1096
|
+
},
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
approvalPresentationRequestKey(taskId: string): string {
|
|
1101
|
+
const task = this.requireTask(taskId)
|
|
1102
|
+
const awaiting = task.awaitingApproval
|
|
1103
|
+
if (!awaiting) throw new Error('approval_not_awaiting')
|
|
1104
|
+
return awaiting.presentationRequestKey
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
commitDecisionBatch(taskId: string, requestKey: string, decisions: readonly BookingPlannerDecision[]): BookingSurfaceEvent[] {
|
|
1108
|
+
return this.applyDecisionBatch(taskId, requestKey, decisions)
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
readDecisionBatch(taskId: string, requestKey: string): BookingSurfaceEvent[] | null {
|
|
1112
|
+
const state = this.requireTask(taskId)
|
|
1113
|
+
if (state.legacySuppressedDecisionRequestKeys?.includes(requestKey)) throw new Error('request_key_invalidated')
|
|
1114
|
+
const rows = this.ledger.db.prepare('SELECT seq, payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq DESC').all(this.ledger.tenant, taskId, DECISION_BATCH) as Array<{ seq: number; payload: string }>
|
|
1115
|
+
for (const row of rows) {
|
|
1116
|
+
const payload = JSON.parse(row.payload) as DecisionBatchPayload
|
|
1117
|
+
if (payload.requestKey !== requestKey) continue
|
|
1118
|
+
if (payload.schema !== LEDGER_SCHEMA || payload.taskId !== taskId || payload.contextRef !== state.contextRef || !Array.isArray(payload.events) || payload.events.some((event) => event.taskId !== taskId || event.contextRef !== state.contextRef)) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
|
|
1119
|
+
const events = this.normalizeDecisionBatchFromDurableRows(taskId, payload.events, row.seq)
|
|
1120
|
+
if (!events) throw new Error(`ledger_corrupt:${taskId}:decision_batch`)
|
|
1121
|
+
for (const event of events) {
|
|
1122
|
+
if (event.kind !== 'question') continue
|
|
1123
|
+
const awaiting = state.awaitingApproval
|
|
1124
|
+
if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingDigest(event.question.blocker) !== bookingDigest(awaiting.blocker) || event.question.approvalOptions.length !== awaiting.options.length || event.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingDigest(option) === bookingDigest(approval)))) throw new Error(`ledger_corrupt:${taskId}:decision_batch_question`)
|
|
1125
|
+
}
|
|
1126
|
+
return events
|
|
1127
|
+
}
|
|
1128
|
+
return null
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Materializes the durable policy/budget terminal response once, before SSE. */
|
|
1132
|
+
terminalDecisionBatch(taskId: string, requestKey: string): BookingSurfaceEvent[] {
|
|
1133
|
+
assertInternalDecisionKey(requestKey)
|
|
1134
|
+
const prior = this.readDecisionBatch(taskId, requestKey)
|
|
1135
|
+
if (prior) return prior
|
|
1136
|
+
const state = this.requireTask(taskId)
|
|
1137
|
+
if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
|
|
1138
|
+
assertReplayUpgradeReanchored(state)
|
|
1139
|
+
const run = this.ledger.db.transaction(() => {
|
|
309
1140
|
const state = this.requireTask(taskId)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
1141
|
+
if (!taskHasTerminalBudget(state) || state.phase !== 'terminal') throw new Error('task_not_terminal')
|
|
1142
|
+
assertReplayUpgradeReanchored(state)
|
|
1143
|
+
const event = this.terminalEvent(state)
|
|
1144
|
+
this.appendTerminalEvent(taskId, event)
|
|
1145
|
+
this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events: [event] }, `booking-copilot:decision-batch:${taskId}:${requestKey}`)
|
|
1146
|
+
return [event]
|
|
1147
|
+
})
|
|
1148
|
+
return run.immediate()
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Atomically applies planner decisions and records the exact SSE batch before it is sent. */
|
|
1152
|
+
applyDecisionBatch(taskId: string, requestKey: string, decisions: readonly BookingPlannerDecision[], includeSubmitted = false): BookingSurfaceEvent[] {
|
|
1153
|
+
assertInternalDecisionKey(requestKey)
|
|
1154
|
+
const prior = this.readDecisionBatch(taskId, requestKey)
|
|
1155
|
+
if (prior) return prior
|
|
1156
|
+
const current = this.requireTask(taskId)
|
|
1157
|
+
if (current.phase === 'terminal' || current.phase === 'error') throw new Error('task_terminal')
|
|
1158
|
+
assertReplayUpgradeReanchored(current)
|
|
1159
|
+
assertDecisionBatchFinality(decisions)
|
|
1160
|
+
assertAvailabilityLiveness(current, decisions)
|
|
1161
|
+
if (current.availability.recoveryStarted && !current.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned')
|
|
1162
|
+
for (const decision of decisions) {
|
|
1163
|
+
if (decision.kind === 'operation') {
|
|
1164
|
+
const checked = validateBookingReadAction(decision.action)
|
|
1165
|
+
if (!checked.ok) throw new Error(`invalid_action:${errorText(checked)}`)
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const run = this.ledger.db.transaction(() => {
|
|
1169
|
+
const events: BookingSurfaceEvent[] = []
|
|
1170
|
+
const before = this.requireTask(taskId)
|
|
1171
|
+
assertReplayUpgradeReanchored(before)
|
|
1172
|
+
assertAvailabilityLiveness(before, decisions)
|
|
1173
|
+
if (before.availability.recoveryStarted && !before.availability.terminal && decisions.some((decision) => decision.kind === 'terminal' || decision.kind === 'error')) throw new Error('availability_terminal_policy_owned')
|
|
1174
|
+
for (const decision of decisions) {
|
|
1175
|
+
if (decision.kind !== 'question') continue
|
|
1176
|
+
const awaiting = before.awaitingApproval
|
|
1177
|
+
if (!awaiting || requestKey !== awaiting.presentationRequestKey || bookingDigest(decision.question.blocker) !== bookingDigest(awaiting.blocker) || decision.question.approvalOptions.length !== awaiting.options.length || decision.question.approvalOptions.some(({ approval }) => !awaiting.options.some((option) => option.optionDigest === approval.optionDigest && bookingDigest(option) === bookingDigest(approval)))) throw new Error('approval_presentation_key_mismatch')
|
|
1178
|
+
this.ensureApprovalOffered(taskId, before.contextRef, awaiting, requestKey)
|
|
1179
|
+
}
|
|
1180
|
+
if (includeSubmitted) events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'submitted' }))
|
|
1181
|
+
events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'working' }))
|
|
1182
|
+
for (const decision of decisions) {
|
|
1183
|
+
if (decision.kind === 'operation') {
|
|
1184
|
+
events.push(this.issueOperationInTransaction(taskId, decision.action))
|
|
1185
|
+
events.push(this.emitEventInTransaction(taskId, { kind: 'status', status: 'waiting_receipt' }))
|
|
319
1186
|
}
|
|
1187
|
+
else events.push(this.emitEventInTransaction(taskId, decision))
|
|
320
1188
|
}
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
1189
|
+
const state = this.requireTask(taskId)
|
|
1190
|
+
this.append(DECISION_BATCH, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, requestKey, events }, `booking-copilot:decision-batch:${taskId}:${requestKey}`)
|
|
1191
|
+
return events
|
|
1192
|
+
})
|
|
1193
|
+
return run.immediate()
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
issueOperation(taskId: string, candidate: BookingReadAction): { schemaVersion: 'booking.surface'; eventId: string; taskId: string; contextRef: string; sequence: number; emittedAt: string; kind: 'operation'; action: BookingReadAction } {
|
|
1197
|
+
const validation = validateBookingReadAction(candidate)
|
|
1198
|
+
if (!validation.ok) throw new Error(`invalid_action:${errorText(validation)}`)
|
|
1199
|
+
assertTaskId(taskId); assertSafeRef(candidate.actionId); candidate.factRefs.forEach(assertSafeRef)
|
|
1200
|
+
return this.ledger.db.transaction(() => this.issueOperationInTransaction(taskId, candidate)).immediate()
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
private issueOperationInTransaction(taskId: string, candidate: BookingReadAction) {
|
|
1204
|
+
assertSafeRef(candidate.actionId); candidate.factRefs.forEach(assertSafeRef)
|
|
1205
|
+
const state = this.requireTask(taskId)
|
|
1206
|
+
if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
|
|
1207
|
+
assertReplayUpgradeReanchored(state)
|
|
1208
|
+
if (state.pendingAction) {
|
|
1209
|
+
if (state.pendingAction.actionId !== candidate.actionId) throw new Error('receipt_required')
|
|
1210
|
+
if (!matchesCheckpoint(candidate, state.pendingAction)) throw new Error('action_conflict')
|
|
1211
|
+
return this.operationEvent(taskId, state.pendingAction, candidate)
|
|
1212
|
+
}
|
|
1213
|
+
if (candidate.contextRef !== state.contextRef) throw new Error('context_mismatch')
|
|
1214
|
+
if (!state.allowedActions.includes(candidate.kind)) throw new Error('unsupported_action')
|
|
1215
|
+
if (candidate.expectedRevision !== state.revision) throw new Error('stale_revision')
|
|
1216
|
+
if (state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) throw new Error('operation_limit_reached')
|
|
1217
|
+
if (candidate.relaxationApprovalRef) throw new Error('approval_ref_planner_owned_forbidden')
|
|
1218
|
+
if (!state.workspaceSnapshot) throw new Error(`ledger_corrupt:${taskId}:workspace_missing`)
|
|
1219
|
+
if (!actionHitsCurrentOfferVersion(state.workspaceSnapshot, candidate, this.now())) throw new Error('offer_version_not_loaded')
|
|
1220
|
+
let action = candidate
|
|
1221
|
+
const availability = reduceAvailabilityAction(state.availability, state.workspaceSnapshot, candidate)
|
|
1222
|
+
if (state.awaitingApproval) {
|
|
1223
|
+
const approval = state.awaitingApproval
|
|
1224
|
+
if (!approval.approval || Date.parse(approval.expiresAt) <= Date.parse(this.now())) throw new Error('approval_expired')
|
|
1225
|
+
const ref: RelaxationApprovalRef = {
|
|
1226
|
+
approvalId: approval.approval.approvalId, blockerId: approval.blocker.blockerId,
|
|
1227
|
+
contextRef: state.contextRef, sourceTurnId: approval.sourceTurnId,
|
|
1228
|
+
presentationRequestKey: approval.presentationRequestKey, sourceActionId: approval.blocker.sourceActionId,
|
|
1229
|
+
targetActionId: candidate.actionId, sourceRevision: candidate.expectedRevision,
|
|
1230
|
+
targetActionKind: candidate.kind, to: approval.approval.to, expiresAt: approval.expiresAt,
|
|
1231
|
+
nonce: approval.nonce, sourceReceiptDigest: approval.blocker.sourceReceiptDigest,
|
|
1232
|
+
scope: approval.blocker.scope, code: approval.blocker.code, criterionPath: approval.blocker.criterionPath,
|
|
1233
|
+
valueDigest: approval.blocker.valueDigest,
|
|
1234
|
+
}
|
|
1235
|
+
action = { ...candidate, relaxationApprovalRef: ref }
|
|
1236
|
+
this.append(APPROVAL_CONSUMED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, approval, ref, approvalDigest: bookingDigest(ref) }, `booking-copilot:approval-consumed:${taskId}:${approval.approval.approvalId}:${approval.nonce}`)
|
|
332
1237
|
}
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
this.
|
|
1238
|
+
const eventId = this.idFactory('event'); const emittedAt = this.now(); const sequence = state.lastSequence + 1
|
|
1239
|
+
const event = { schemaVersion: 'booking.surface' as const, eventId, taskId, contextRef: state.contextRef, sequence, emittedAt, kind: 'operation' as const, action }
|
|
1240
|
+
const eventValidation = validateBookingSurfaceEvent(event)
|
|
1241
|
+
if (!eventValidation.ok) throw new Error(`invalid_operation_event:${errorText(eventValidation)}`)
|
|
1242
|
+
if (!state.lastTurnId) throw new Error(`ledger_corrupt:${taskId}:turn_id`)
|
|
1243
|
+
this.appendAction(taskId, state.contextRef, checkpointFor(action, eventId, sequence, emittedAt, state.lastTurnId), state.operationCount + 1, availability)
|
|
339
1244
|
return event
|
|
340
|
-
})
|
|
341
|
-
return apply.immediate()
|
|
342
1245
|
}
|
|
343
1246
|
|
|
344
|
-
continueWithReceipt(turn:
|
|
345
|
-
const validation =
|
|
346
|
-
if (!validation.ok
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
const apply = this.ledger.db.transaction(() => {
|
|
1247
|
+
continueWithReceipt(turn: Extract<BookingCopilotTurn, { kind: 'action.receipt.continuation' }>): BookingCopilotTaskState {
|
|
1248
|
+
const validation = validateBookingSurface(turn)
|
|
1249
|
+
if (!validation.ok) throw new Error(`invalid_receipt_continuation:${errorText(validation)}`)
|
|
1250
|
+
assertWorkspaceLoadedOfferRefsUnique(turn.workspace)
|
|
1251
|
+
assertCanonicalReceipt(turn.receipt)
|
|
1252
|
+
const run = this.ledger.db.transaction(() => {
|
|
351
1253
|
const state = this.requireTask(turn.taskId)
|
|
1254
|
+
assertReplayUpgradeReanchored(state)
|
|
352
1255
|
if (turn.workspace.contextRef !== state.contextRef || turn.receipt.contextRef !== state.contextRef) throw new Error('context_mismatch')
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
).all(this.ledger.tenant, turn.taskId,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1256
|
+
const receiptWorkspace = bookingWorkspaceDigest(turn.workspace)
|
|
1257
|
+
const receiptWorkspaceSemantic = bookingWorkspaceSemanticDigest(turn.workspace)
|
|
1258
|
+
if (!state.workspaceSnapshot || !workspaceBoundaryMatches(state.workspaceSnapshot, turn.workspace)) throw new Error('workspace_mismatch')
|
|
1259
|
+
const existing = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? ORDER BY seq`).all(this.ledger.tenant, turn.taskId, RECEIPT) as Array<{ payload: string }>
|
|
1260
|
+
const receiptDigest = canonicalReceiptDigest(turn.receipt)
|
|
1261
|
+
if (state.legacySuppressedReceiptActionIds?.includes(turn.receipt.actionId)) throw new Error('request_key_invalidated')
|
|
1262
|
+
for (const row of existing) {
|
|
1263
|
+
const prior = JSON.parse(row.payload) as ReceiptPayload
|
|
1264
|
+
if (prior.receipt.actionId !== turn.receipt.actionId) continue
|
|
361
1265
|
if (prior.receiptDigest !== receiptDigest) throw new Error('receipt_conflict')
|
|
1266
|
+
if (prior.workspaceDigest !== receiptWorkspace) throw new Error('workspace_mismatch')
|
|
362
1267
|
return state
|
|
363
1268
|
}
|
|
1269
|
+
if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
|
|
364
1270
|
if (!state.pendingAction) throw new Error('unexpected_receipt')
|
|
365
1271
|
if (state.pendingAction.actionId !== turn.receipt.actionId) throw new Error('receipt_action_mismatch')
|
|
366
1272
|
if (turn.receipt.revision < state.pendingAction.expectedRevision) throw new Error('revision_regression')
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
1273
|
+
if (turn.receipt.resultContract.blockers.some((blocker) => blocker.sourceActionId !== state.pendingAction!.actionId)) throw new Error('receipt_source_action_mismatch')
|
|
1274
|
+
if (!workspacePostActionMatches(state.workspaceSnapshot, turn.workspace, state.pendingAction, turn.receipt)) throw new Error('workspace_mismatch')
|
|
1275
|
+
if (!receiptObservationMatchesAction(turn.receipt.observation.kind, state.pendingAction.kind)) throw new Error('receipt_observation_action_mismatch')
|
|
1276
|
+
if (!receiptTargetMatchesAction(turn.receipt, state.pendingAction)) throw new Error('receipt_target_mismatch')
|
|
1277
|
+
if (state.pendingAction.kind === 'offer.check' && turn.receipt.status === 'applied' && !verifiedOfferUnexpired(turn.workspace, this.now())) throw new Error('receipt_verified_offer_expired')
|
|
1278
|
+
// Failed/stale query receipts may carry a typed gap observation; only an
|
|
1279
|
+
// offers.state observation can claim passive offer provenance.
|
|
1280
|
+
if (state.pendingAction.kind === 'offers.query' && turn.receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspace(turn.receipt, turn.workspace, state.pendingAction.input.hotelRefs)
|
|
1281
|
+
if (turn.receipt.resultContract.relaxationsApplied.some((approval) => !this.approvalWasConsumed(turn.taskId, approval, state.pendingAction!.actionId))) throw new Error('receipt_relaxation_unauthorized')
|
|
1282
|
+
const pending = state.pendingAction
|
|
1283
|
+
if (turn.receipt.revision !== turn.workspace.revision) throw new Error('revision_mismatch')
|
|
1284
|
+
const availability = reduceAvailabilityReceipt(state.availability, turn.workspace, turn.receipt, pending)
|
|
1285
|
+
const availabilityTerminal = availabilityPolicyTerminal(availability)
|
|
1286
|
+
this.append(RECEIPT, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, receipt: turn.receipt, receiptDigest, operationCount: state.operationCount, workspaceDigest: receiptWorkspace, workspaceSemanticDigest: receiptWorkspaceSemantic, workspace: turn.workspace, availability, availabilityDigest: bookingDigest(availability), ...(availabilityTerminal ? { availabilityTerminal } : {}) }, `booking-copilot:receipt:${turn.taskId}:${turn.receipt.actionId}`)
|
|
1287
|
+
if (availabilityTerminal || state.operationCount >= BOOKING_COPILOT_MAX_OPERATIONS) {
|
|
1288
|
+
const terminalState = this.requireTask(turn.taskId)
|
|
1289
|
+
const terminalEvent = this.terminalEvent(terminalState)
|
|
1290
|
+
this.appendTerminalEvent(turn.taskId, terminalEvent)
|
|
1291
|
+
this.append(DECISION_BATCH, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, requestKey: `receipt:${turn.receipt.actionId}:${receiptDigest}`, events: [terminalEvent] }, `booking-copilot:decision-batch:${turn.taskId}:receipt:${turn.receipt.actionId}:${receiptDigest}`)
|
|
1292
|
+
return terminalState
|
|
1293
|
+
}
|
|
1294
|
+
const blocker = turn.receipt.resultContract.blockers[0]
|
|
1295
|
+
if (blocker) {
|
|
1296
|
+
// Never derive this claim from the injectable id factory: production
|
|
1297
|
+
// and crash proofs must not make a presentation nonce guessable from
|
|
1298
|
+
// deterministic task/event ids.
|
|
1299
|
+
const nonce = randomUUID()
|
|
1300
|
+
const sourceTurnId = state.pendingAction?.sourceTurnId ?? state.lastTurnId ?? `ordinal:${state.userTurnCount}`
|
|
1301
|
+
const requestKey = presentationKey(turn.taskId, sourceTurnId, turn.receipt.actionId, receiptDigest)
|
|
1302
|
+
const approval = { blocker, options: [approvalOption(turn.taskId, state.contextRef, sourceTurnId, requestKey, blocker, 'prefer', nonce), approvalOption(turn.taskId, state.contextRef, sourceTurnId, requestKey, blocker, 'drop', nonce)], optionsEmitted: false, approval: undefined as never, nonce, expiresAt: new Date(Date.parse(this.now()) + this.approvalTtlMs).toISOString(), sourceTurnId, presentationRequestKey: requestKey }
|
|
1303
|
+
this.append(APPROVAL_GRANTED, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: state.contextRef, approval, approvalDigest: bookingDigest(approval) }, `booking-copilot:approval-awaiting:${turn.taskId}:${turn.receipt.actionId}`)
|
|
1304
|
+
}
|
|
373
1305
|
return this.requireTask(turn.taskId)
|
|
374
1306
|
})
|
|
375
|
-
return
|
|
1307
|
+
return run.immediate()
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
emitEvent(taskId: string, draft: BookingSurfaceEventDraft): Exclude<BookingSurfaceEvent, { kind: 'operation' }> {
|
|
1311
|
+
return this.ledger.db.transaction(() => {
|
|
1312
|
+
const state = this.requireTask(taskId)
|
|
1313
|
+
if (state.phase === 'terminal' || state.phase === 'error') throw new Error('task_terminal')
|
|
1314
|
+
assertReplayUpgradeReanchored(state)
|
|
1315
|
+
return this.emitEventInTransaction(taskId, draft)
|
|
1316
|
+
}).immediate()
|
|
376
1317
|
}
|
|
377
1318
|
|
|
378
|
-
|
|
379
|
-
// Sequence allocation is part of the same immediate transaction as the
|
|
380
|
-
// durable append. This makes the fold read and next-sequence write one
|
|
381
|
-
// critical section across independent runtime processes.
|
|
382
|
-
const apply = this.ledger.db.transaction(() => {
|
|
1319
|
+
private emitEventInTransaction(taskId: string, draft: BookingSurfaceEventDraft) {
|
|
383
1320
|
const state = this.requireTask(taskId)
|
|
1321
|
+
assertReplayUpgradeReanchored(state)
|
|
384
1322
|
const safeDraft = draft.kind === 'error'
|
|
385
1323
|
? { ...draft, error: { ...draft.error, code: normalizeBookingErrorCode(draft.error.code), message: safeBookingErrorMessage(normalizeBookingErrorCode(draft.error.code)) } }
|
|
386
1324
|
: draft
|
|
387
|
-
const event = {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
contextRef: state.contextRef,
|
|
392
|
-
sequence: state.lastSequence + 1,
|
|
393
|
-
emittedAt: this.now(),
|
|
394
|
-
...safeDraft,
|
|
395
|
-
} as Exclude<BookingSurfaceEventV1, BookingOperationEventV1>
|
|
396
|
-
const validation = validateBookingSurfaceEventV1(event)
|
|
397
|
-
if (!validation.ok) throw new Error(`invalid_event:${validation.errors.join('; ')}`)
|
|
398
|
-
const payload: EventEmittedPayload = {
|
|
399
|
-
schema: LEDGER_SCHEMA,
|
|
400
|
-
taskId,
|
|
401
|
-
contextRef: state.contextRef,
|
|
402
|
-
eventId: event.eventId,
|
|
403
|
-
sequence: event.sequence,
|
|
404
|
-
emittedAt: event.emittedAt,
|
|
405
|
-
eventKind: event.kind,
|
|
406
|
-
contentDigest: digest(safeDraft),
|
|
407
|
-
}
|
|
408
|
-
this.appendLedgerEvent(EVENT_EMITTED, taskId, payload, `booking-copilot:event:${taskId}:${event.eventId}`)
|
|
1325
|
+
const event = { schemaVersion: 'booking.surface' as const, eventId: this.idFactory('event'), taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), ...safeDraft } as Exclude<BookingSurfaceEvent, { kind: 'operation' }>
|
|
1326
|
+
const checked = validateBookingSurfaceEvent(event)
|
|
1327
|
+
if (!checked.ok) throw new Error(`invalid_event:${errorText(checked)}`)
|
|
1328
|
+
this.append(EVENT, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef: state.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: event.kind, ...(event.kind === 'status' ? { status: event.status } : {}), contentDigest: bookingDigest(safeDraft) }, `booking-copilot:event:${taskId}:${event.eventId}`)
|
|
409
1329
|
return event
|
|
410
|
-
})
|
|
411
|
-
return apply.immediate()
|
|
412
1330
|
}
|
|
413
1331
|
|
|
414
|
-
private
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
1332
|
+
private operationEvent(taskId: string, checkpoint: BookingActionCheckpoint, action: BookingReadAction) {
|
|
1333
|
+
const replayAction = action.relaxationApprovalRef || !checkpoint.relaxationApprovalRef ? action : { ...action, relaxationApprovalRef: checkpoint.relaxationApprovalRef }
|
|
1334
|
+
return { schemaVersion: 'booking.surface' as const, eventId: checkpoint.eventId, taskId, contextRef: checkpoint.contextRef, sequence: checkpoint.sequence, emittedAt: checkpoint.emittedAt, kind: 'operation' as const, action: replayAction }
|
|
1335
|
+
}
|
|
1336
|
+
private terminalEvent(state: BookingCopilotTaskState): Extract<BookingSurfaceEvent, { kind: 'terminal' }> {
|
|
1337
|
+
if (!taskHasTerminalBudget(state)) throw new Error('task_not_terminal')
|
|
1338
|
+
const terminalCode = state.availability.terminal?.code ?? 'operation_limit_reached'
|
|
1339
|
+
const event = { schemaVersion: 'booking.surface' as const, eventId: `terminal-${state.taskId}-${state.operationCount}`, taskId: state.taskId, contextRef: state.contextRef, sequence: state.lastSequence + 1, emittedAt: this.now(), kind: 'terminal' as const, terminal: { status: state.availability.terminal?.code === 'availability_confirmed' ? 'completed' as const : 'stopped' as const, summary: terminalCode, factRefs: [] } }
|
|
1340
|
+
const checked = validateBookingSurfaceEvent(event)
|
|
1341
|
+
if (!checked.ok) throw new Error(`invalid_terminal_event:${errorText(checked)}`)
|
|
1342
|
+
return event
|
|
418
1343
|
}
|
|
419
1344
|
|
|
420
|
-
private
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
1345
|
+
private requireTask(taskId: string): BookingCopilotTaskState { const state = this.resumeTask(taskId); if (!state) throw new Error('task_not_found'); return state }
|
|
1346
|
+
private requestBinding(requestKey: string): RequestBindingPayload | null {
|
|
1347
|
+
const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND kind = ? ORDER BY seq DESC`).all(this.ledger.tenant, REQUEST_BINDING) as Array<{ payload: string }>
|
|
1348
|
+
for (const row of rows) {
|
|
1349
|
+
const binding = JSON.parse(row.payload) as RequestBindingPayload
|
|
1350
|
+
if (binding.requestKey === requestKey) {
|
|
1351
|
+
const state = this.resumeTask(binding.taskId)
|
|
1352
|
+
if (state?.legacySuppressedRequestBindingKeys?.includes(requestKey)) throw new Error('request_key_invalidated')
|
|
1353
|
+
return binding
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return null
|
|
1357
|
+
}
|
|
1358
|
+
private requestBindingPayload(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): Omit<RequestBindingPayload, 'schema' | 'taskId' | 'contextRef'> {
|
|
1359
|
+
assertPrincipal(input.principal)
|
|
1360
|
+
assertRequestKey(requestKey)
|
|
1361
|
+
if (input.taskHandle) assertSafeRef(input.taskHandle)
|
|
1362
|
+
return {
|
|
1363
|
+
requestKey,
|
|
1364
|
+
requestDigest: bookingTurnDigest(turn),
|
|
1365
|
+
turnId: turn.turnId,
|
|
1366
|
+
workspaceDigest: bookingWorkspaceDigest(turn.workspace),
|
|
1367
|
+
workspaceSemanticDigest: bookingWorkspaceSemanticDigest(turn.workspace),
|
|
1368
|
+
surface: turn.workspace.surface,
|
|
1369
|
+
capabilityDigest: bookingDigest(turn.workspace.capabilities),
|
|
1370
|
+
principalDigest: bookingDigest({ subject: input.principal.subject }),
|
|
1371
|
+
scopeDigest: bookingDigest({ scope: input.principal.scope }),
|
|
1372
|
+
...(input.taskHandle ? { taskHandle: input.taskHandle } : {}),
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
private appendRequestBindingInTransaction(requestKey: string, turn: UserTurn, input: BookingIngressRequestBindingInput): void {
|
|
1376
|
+
assertRequestKey(requestKey)
|
|
1377
|
+
assertPrincipal(input.principal)
|
|
1378
|
+
const payload = this.requestBindingPayload(requestKey, turn, input)
|
|
1379
|
+
if (input.taskHandle) {
|
|
1380
|
+
const prior = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND kind = ?`).all(this.ledger.tenant, REQUEST_BINDING) as Array<{ payload: string }>
|
|
1381
|
+
for (const row of prior) {
|
|
1382
|
+
const binding = JSON.parse(row.payload) as RequestBindingPayload
|
|
1383
|
+
if (binding.taskHandle !== input.taskHandle) continue
|
|
1384
|
+
if (binding.principalDigest !== payload.principalDigest || binding.scopeDigest !== payload.scopeDigest) throw new Error('principal_conflict')
|
|
1385
|
+
if (binding.taskId !== turn.taskId || binding.contextRef !== turn.workspace.contextRef) throw new Error('task_handle_conflict')
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
const existing = this.requestBinding(requestKey)
|
|
1389
|
+
if (existing) {
|
|
1390
|
+
if (existing.taskId !== turn.taskId || existing.contextRef !== turn.workspace.contextRef || existing.requestDigest !== payload.requestDigest || existing.turnId !== payload.turnId || existing.workspaceDigest !== payload.workspaceDigest || existing.workspaceSemanticDigest !== payload.workspaceSemanticDigest || existing.surface !== payload.surface || existing.capabilityDigest !== payload.capabilityDigest || existing.principalDigest !== payload.principalDigest || existing.scopeDigest !== payload.scopeDigest || existing.taskHandle !== payload.taskHandle) throw new Error('request_conflict')
|
|
1391
|
+
return
|
|
1392
|
+
}
|
|
1393
|
+
this.append(REQUEST_BINDING, turn.taskId, { schema: LEDGER_SCHEMA, taskId: turn.taskId, contextRef: turn.workspace.contextRef, ...payload }, `booking-copilot:request-binding:${requestKey}`)
|
|
1394
|
+
}
|
|
1395
|
+
private normalizeDecisionBatchFromDurableRows(taskId: string, batchEvents: readonly BookingSurfaceEvent[], batchSeq: number): BookingSurfaceEvent[] | null {
|
|
1396
|
+
const individualRows = this.ledger.db.prepare(`SELECT kind, payload FROM events WHERE tenant_id = ? AND run_id = ? AND seq < ? AND kind IN (?, ?) ORDER BY seq`).all(this.ledger.tenant, taskId, batchSeq, EVENT, ACTION) as Array<{ kind: string; payload: string }>
|
|
1397
|
+
const turnRows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ? AND seq < ?`).all(this.ledger.tenant, taskId, TURN, batchSeq) as Array<{ payload: string }>
|
|
1398
|
+
const sourceWorkspaces = new Map<string, BookingWorkspaceSnapshot>()
|
|
1399
|
+
for (const row of turnRows) {
|
|
1400
|
+
try {
|
|
1401
|
+
const turn = JSON.parse(row.payload) as TurnPayload
|
|
1402
|
+
sourceWorkspaces.set(turn.turnId, normalizeWorkspaceForReplay(turn.workspace))
|
|
1403
|
+
} catch {
|
|
1404
|
+
return null
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const recordedEvents = new Map<string, EventPayload>()
|
|
1408
|
+
const recordedActions = new Map<string, BookingActionCheckpoint>()
|
|
1409
|
+
for (const row of individualRows) {
|
|
1410
|
+
if (row.kind === EVENT) {
|
|
1411
|
+
const payload = JSON.parse(row.payload) as EventPayload
|
|
1412
|
+
const key = consumedIdentityKey(payload.eventId, payload.sequence)
|
|
1413
|
+
if (recordedEvents.has(key) || recordedActions.has(key)) return null
|
|
1414
|
+
recordedEvents.set(key, payload)
|
|
1415
|
+
} else if (row.kind === ACTION) {
|
|
1416
|
+
const payload = JSON.parse(row.payload) as ActionPayload
|
|
1417
|
+
let action = payload.action
|
|
1418
|
+
try {
|
|
1419
|
+
action = normalizeActionCheckpointForReplay(action, sourceWorkspaces.get(action.sourceTurnId))
|
|
1420
|
+
} catch {
|
|
1421
|
+
return null
|
|
1422
|
+
}
|
|
1423
|
+
const key = consumedIdentityKey(action.eventId, action.sequence)
|
|
1424
|
+
if (recordedEvents.has(key) || recordedActions.has(key)) return null
|
|
1425
|
+
recordedActions.set(key, action)
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
let lastBatchSequence = -1
|
|
1429
|
+
const seen = new Set<string>()
|
|
1430
|
+
const normalizedEvents: BookingSurfaceEvent[] = []
|
|
1431
|
+
for (const event of batchEvents) {
|
|
1432
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence <= lastBatchSequence) return null
|
|
1433
|
+
lastBatchSequence = event.sequence
|
|
1434
|
+
const key = consumedIdentityKey(event.eventId, event.sequence)
|
|
1435
|
+
if (seen.has(key)) return null
|
|
1436
|
+
seen.add(key)
|
|
1437
|
+
if (event.kind === 'operation') {
|
|
1438
|
+
const checkpoint = recordedActions.get(key)
|
|
1439
|
+
if (!checkpoint || !operationEventMatchesAction(event, checkpoint)) return null
|
|
1440
|
+
const normalized = { ...event, action: actionFromOperationEventForReplay(event, checkpoint) } as BookingSurfaceEvent
|
|
1441
|
+
if (!validateBookingSurfaceEvent(normalized).ok) return null
|
|
1442
|
+
normalizedEvents.push(normalized)
|
|
1443
|
+
} else {
|
|
1444
|
+
const recorded = recordedEvents.get(key)
|
|
1445
|
+
if (!recorded || !validateBookingSurfaceEvent(event).ok || !eventPayloadMatchesEvent(recorded, event)) return null
|
|
1446
|
+
normalizedEvents.push(event)
|
|
1447
|
+
}
|
|
443
1448
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
)
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
private
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
).run(
|
|
458
|
-
|
|
459
|
-
this.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
)
|
|
467
|
-
if (
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
`INSERT INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id)
|
|
478
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
479
|
-
).run(this.ledger.tenant, this.now(), LEDGER_ACTOR, ACTION_ISSUED, taskId, JSON.stringify(payload), idemKey, taskId)
|
|
480
|
-
if (result.changes !== 1) throw new Error('ledger_write_failed:action')
|
|
481
|
-
return Number(result.lastInsertRowid)
|
|
1449
|
+
return normalizedEvents
|
|
1450
|
+
}
|
|
1451
|
+
private rows(taskId: string): Row[] { return this.ledger.db.prepare(`SELECT seq, kind, payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ORDER BY seq`).all(this.ledger.tenant, taskId, STARTED, TURN, ACTION, RECEIPT, EVENT, APPROVAL_GRANTED, APPROVAL_OFFERED, APPROVAL_CONSUMED, DECISION_BATCH, REQUEST_BINDING) as Row[] }
|
|
1452
|
+
private append(kind: string, taskId: string, payload: BasePayload, idemKey: string): void {
|
|
1453
|
+
const result = this.ledger.db.prepare(`INSERT OR IGNORE INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), ACTOR, kind, taskId, JSON.stringify(payload), idemKey, taskId)
|
|
1454
|
+
if (result.changes === 0) throw new Error(`ledger_write_failed:${kind}`)
|
|
1455
|
+
}
|
|
1456
|
+
private appendTurn(taskId: string, contextRef: string, requestDigest: string, workspaceDigest: string, workspaceSemanticDigest: string, ordinal: number, turnId: string, workspace?: BookingWorkspaceSnapshot): void { if (!workspace) throw new Error('ledger_write_failed:turn_workspace'); this.append(TURN, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, requestDigest, workspaceDigest, workspaceSemanticDigest, workspace, turnId }, `booking-copilot:turn:${taskId}:${turnId}`) }
|
|
1457
|
+
private appendApproval(taskId: string, contextRef: string, approval: BookingApprovalState): void { this.append(APPROVAL_GRANTED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingDigest(approval) }, `booking-copilot:approval-granted:${taskId}:${approval.approval?.approvalId ?? 'pending'}:${approval.nonce}`) }
|
|
1458
|
+
private appendAction(taskId: string, contextRef: string, action: BookingActionCheckpoint, operationCount: number, availability: AvailabilityPolicyState): void { this.append(ACTION, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, action, operationCount, availability, availabilityDigest: bookingDigest(availability) }, `booking-copilot:action:${taskId}:${action.actionId}`) }
|
|
1459
|
+
private appendTerminalEvent(taskId: string, event: Extract<BookingSurfaceEvent, { kind: 'terminal' }>): void {
|
|
1460
|
+
const payload: EventPayload = { schema: LEDGER_SCHEMA, taskId, contextRef: event.contextRef, eventId: event.eventId, sequence: event.sequence, emittedAt: event.emittedAt, eventKind: 'terminal', contentDigest: bookingDigest(event.terminal) }
|
|
1461
|
+
const idemKey = `booking-copilot:terminal-event:${taskId}`
|
|
1462
|
+
const result = this.ledger.db.prepare(`INSERT OR IGNORE INTO events (tenant_id, ts, actor, kind, subject_id, payload, idem_key, run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(this.ledger.tenant, this.now(), ACTOR, EVENT, taskId, JSON.stringify(payload), idemKey, taskId)
|
|
1463
|
+
if (result.changes === 0) {
|
|
1464
|
+
const existing = this.ledger.db.prepare('SELECT payload FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey) as { payload: string } | undefined
|
|
1465
|
+
if (!existing || (JSON.parse(existing.payload) as EventPayload).contentDigest !== payload.contentDigest) throw new Error('ledger_write_failed:terminal_event')
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
private ensureApprovalOffered(taskId: string, contextRef: string, approval: BookingApprovalState, requestKey: string): void {
|
|
1469
|
+
if (requestKey !== approval.presentationRequestKey) throw new Error('approval_presentation_key_mismatch')
|
|
1470
|
+
const idemKey = `booking-copilot:approval-offered:${taskId}:${requestKey}`
|
|
1471
|
+
const exists = this.ledger.db.prepare('SELECT 1 AS present FROM events WHERE tenant_id = ? AND idem_key = ?').get(this.ledger.tenant, idemKey) as { present: number } | undefined
|
|
1472
|
+
if (!exists) this.append(APPROVAL_OFFERED, taskId, { schema: LEDGER_SCHEMA, taskId, contextRef, approval, approvalDigest: bookingDigest(approval) }, idemKey)
|
|
1473
|
+
}
|
|
1474
|
+
private approvalWasConsumed(taskId: string, approval: RelaxationApproval, targetActionId: string): boolean {
|
|
1475
|
+
const state = this.resumeTask(taskId)
|
|
1476
|
+
if (state?.legacySuppressedApprovalTargetActionIds?.includes(targetActionId)) return false
|
|
1477
|
+
const rows = this.ledger.db.prepare(`SELECT payload FROM events WHERE tenant_id = ? AND run_id = ? AND kind = ?`).all(this.ledger.tenant, taskId, APPROVAL_CONSUMED) as Array<{ payload: string }>
|
|
1478
|
+
return rows.some(({ payload }) => {
|
|
1479
|
+
const consumed = JSON.parse(payload) as ApprovalPayload
|
|
1480
|
+
return consumed.ref?.targetActionId === targetActionId && consumed.approval?.approval && bookingDigest(consumed.approval.approval) === bookingDigest(approval)
|
|
1481
|
+
})
|
|
482
1482
|
}
|
|
483
1483
|
}
|