@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,74 +1,73 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { OfferCriteriaV1 } from './contracts.ts'
|
|
1
|
+
import type { ActionReceipt, BookingReadAction, BookingWorkspaceSnapshot, OfferCriteria } from './contracts.ts'
|
|
3
2
|
import { createHash } from 'node:crypto'
|
|
4
3
|
|
|
5
4
|
/** Typed CheckAvail budget. This module never parses prose or retries a supplier. */
|
|
6
|
-
export const
|
|
7
|
-
export const
|
|
8
|
-
export const
|
|
9
|
-
export const
|
|
5
|
+
export const MAX_HOTELS_PER_TASK = 5
|
|
6
|
+
export const MAX_OFFERS_PER_HOTEL_GENERATION = 3
|
|
7
|
+
export const MAX_OFFER_CHECKS_PER_HOTEL = 2
|
|
8
|
+
export const MAX_OFFER_QUERIES_PER_HOTEL = 2
|
|
10
9
|
|
|
11
|
-
export type
|
|
12
|
-
export type
|
|
10
|
+
export type AvailabilityHotelStatus = 'unvisited' | 'active' | 'negative' | 'confirmed' | 'inconclusive'
|
|
11
|
+
export type AvailabilityGenerationSource =
|
|
13
12
|
| { kind: 'query_receipt'; actionId: string; receiptDigest: string; workspaceRevision: number }
|
|
14
13
|
| { kind: 'workspace_snapshot'; workspaceDigest: string; workspaceRevision: number }
|
|
15
|
-
export interface
|
|
16
|
-
export interface
|
|
17
|
-
hotelRef: string; status:
|
|
18
|
-
currentGeneration?:
|
|
14
|
+
export interface AvailabilityGeneration { generationId: string; source: AvailabilityGenerationSource; offerSetDigest: string; orderedOfferRefs: string[]; evidence: 'complete' | 'partial'; valid: boolean }
|
|
15
|
+
export interface AvailabilityHotelState {
|
|
16
|
+
hotelRef: string; status: AvailabilityHotelStatus; checksIssued: number; offerQueriesIssued: number; generationNo: number
|
|
17
|
+
currentGeneration?: AvailabilityGeneration
|
|
19
18
|
/** Compatibility projection for callers needing current refs only. */
|
|
20
19
|
generation: number; currentOfferRefs: string[]; invalidatedOfferRefs: string[]; checkCount: number; freshOffersRequired: boolean
|
|
21
20
|
tombstonedOfferRefs: string[]; tombstonedOfferVersionRefs: string[]
|
|
22
21
|
lastEvidence: 'none' | 'confirmed' | 'unavailable' | 'inconclusive'
|
|
23
22
|
}
|
|
24
|
-
export interface
|
|
25
|
-
export interface
|
|
26
|
-
export interface
|
|
23
|
+
export interface AvailabilityAttempt { actionId: string; hotelRef: string; offerRef: string; offerVersionRef: string; generation: number; ordinal: number; workspaceRevision: number }
|
|
24
|
+
export interface AvailabilityQueryReservation { actionId: string; hotelRefs: string[]; workspaceRevision: number }
|
|
25
|
+
export interface AvailabilityExhaustion {
|
|
27
26
|
code: 'availability_confirmed' | 'availability_exhausted_complete' | 'availability_exhausted_inconclusive'
|
|
28
27
|
hotelRefs: string[]; reason: 'no_current_offers' | 'check_limit_reached' | 'confirmed'; evidence: 'conclusive' | 'inconclusive'
|
|
29
28
|
}
|
|
30
|
-
export interface
|
|
29
|
+
export interface AvailabilityPolicyState {
|
|
31
30
|
initialized: boolean; recoveryStarted: boolean
|
|
32
31
|
availabilityPhase: 'need_offers' | 'waiting_offers' | 'need_check' | 'waiting_check' | 'terminal'
|
|
33
|
-
activeHotelOrdinal: number; hotelRefs: string[]; hotels: Record<string,
|
|
34
|
-
recoveryId?: string; criteria?:
|
|
32
|
+
activeHotelOrdinal: number; hotelRefs: string[]; hotels: Record<string, AvailabilityHotelState>; attempts: AvailabilityAttempt[]; queryReservations: AvailabilityQueryReservation[]; terminal?: AvailabilityExhaustion
|
|
33
|
+
recoveryId?: string; criteria?: OfferCriteria; criteriaDigest?: string; candidateSetDigest?: string; lastQueryHotelRefs?: string[]; lastQueryCriteriaDigest?: string
|
|
35
34
|
/** Receipt-authoritative mapping used when a shortlist ref is no longer loaded. */
|
|
36
35
|
lastQueryOfferHotels?: Record<string, string>; lastQuerySourceActionId?: string; lastQuerySourceReceiptDigest?: string; lastQueryWorkspaceRevision?: number
|
|
37
36
|
}
|
|
38
|
-
export type
|
|
37
|
+
export type AvailabilityPolicyDecision =
|
|
39
38
|
| { ok: true; hotelRef: string; offerRef: string; offerVersionRef: string; checkCount: number; generation: number }
|
|
40
39
|
| { ok: false; code: 'hotel_unknown' | 'offer_not_loaded' | 'offers_refresh_required' | 'hotel_check_limit_reached' | 'availability_exhausted' | 'generation_invalid' | 'hotel_not_active' }
|
|
41
40
|
|
|
42
|
-
type
|
|
41
|
+
type AvailabilityActionLike = { kind: BookingReadAction['kind']; actionId: string; input: Record<string, any>; expectedRevision?: number }
|
|
43
42
|
|
|
44
43
|
function unique(values: readonly string[]): string[] { return [...new Set(values)] }
|
|
45
|
-
export function
|
|
44
|
+
export function assertWorkspaceLoadedOfferRefsUnique(workspace: BookingWorkspaceSnapshot): void {
|
|
46
45
|
if (workspace.loadedOffers.length !== new Set(workspace.loadedOffers.map((offer) => offer.offerRef)).size) throw new Error('availability_duplicate_offer_ref')
|
|
47
46
|
if (workspace.loadedOffers.length !== new Set(workspace.loadedOffers.map((offer) => offer.offerVersionRef)).size) throw new Error('availability_duplicate_offer_version_ref')
|
|
48
47
|
}
|
|
49
48
|
function stable(value: unknown): unknown { if (Array.isArray(value)) return value.map(stable); if (!value || typeof value !== 'object') return value; return Object.fromEntries(Object.keys(value as Record<string, unknown>).sort().map((key) => [key, stable((value as Record<string, unknown>)[key])])); }
|
|
50
|
-
export function
|
|
51
|
-
function
|
|
52
|
-
function offerRefsForHotel(workspace:
|
|
49
|
+
export function digest(value: unknown): string { return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex') }
|
|
50
|
+
function workspaceDigest(workspace: BookingWorkspaceSnapshot): string { const { contextRef: _context, ...bound } = workspace as unknown as Record<string, unknown>; return digest(bound) }
|
|
51
|
+
function offerRefsForHotel(workspace: BookingWorkspaceSnapshot, hotelRef: string, selectedOfferRef?: string): string[] {
|
|
53
52
|
const refs = unique(workspace.loadedOffers.filter((offer) => offer.hotelRef === hotelRef).map((offer) => offer.offerRef))
|
|
54
|
-
if (selectedOfferRef) return unique([selectedOfferRef, ...refs]).slice(0,
|
|
55
|
-
if (refs.length >
|
|
53
|
+
if (selectedOfferRef) return unique([selectedOfferRef, ...refs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION)
|
|
54
|
+
if (refs.length > MAX_OFFERS_PER_HOTEL_GENERATION) throw new Error('availability_offer_limit_exceeded')
|
|
56
55
|
return refs
|
|
57
56
|
}
|
|
58
|
-
function loadedOfferVersion(workspace:
|
|
59
|
-
function legalOfferRefsForHotel(workspace:
|
|
57
|
+
function loadedOfferVersion(workspace: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string) { return workspace.loadedOffers.find((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef) }
|
|
58
|
+
function legalOfferRefsForHotel(workspace: BookingWorkspaceSnapshot, hotel: AvailabilityHotelState, refs: readonly string[]): string[] {
|
|
60
59
|
const legal = refs.filter((ref) => {
|
|
61
60
|
if (hotel.tombstonedOfferRefs.includes(ref)) return false
|
|
62
61
|
const loaded = workspace.loadedOffers.find((offer) => offer.offerRef === ref && offer.hotelRef === hotel.hotelRef)
|
|
63
62
|
return Boolean(loaded && !hotel.tombstonedOfferVersionRefs.includes(loaded.offerVersionRef))
|
|
64
63
|
})
|
|
65
|
-
if (legal.length >
|
|
64
|
+
if (legal.length > MAX_OFFERS_PER_HOTEL_GENERATION) throw new Error('availability_offer_limit_exceeded')
|
|
66
65
|
return unique(legal)
|
|
67
66
|
}
|
|
68
|
-
function copyHotel(hotel:
|
|
67
|
+
function copyHotel(hotel: AvailabilityHotelState): AvailabilityHotelState {
|
|
69
68
|
return { ...hotel, currentOfferRefs: [...hotel.currentOfferRefs], invalidatedOfferRefs: [...hotel.invalidatedOfferRefs], tombstonedOfferRefs: [...hotel.tombstonedOfferRefs], tombstonedOfferVersionRefs: [...hotel.tombstonedOfferVersionRefs], ...(hotel.currentGeneration ? { currentGeneration: { ...hotel.currentGeneration, orderedOfferRefs: [...hotel.currentGeneration.orderedOfferRefs] } } : {}) }
|
|
70
69
|
}
|
|
71
|
-
function copyState(state:
|
|
70
|
+
function copyState(state: AvailabilityPolicyState): AvailabilityPolicyState {
|
|
72
71
|
return { ...state, ...(state.criteria ? { criteria: structuredClone(state.criteria) } : {}), hotelRefs: [...state.hotelRefs], hotels: Object.fromEntries(Object.entries(state.hotels).map(([ref, hotel]) => [ref, copyHotel(hotel)])), attempts: state.attempts.map((attempt) => ({ ...attempt })), queryReservations: state.queryReservations.map((query) => ({ ...query, hotelRefs: [...query.hotelRefs] })), ...(state.lastQueryHotelRefs ? { lastQueryHotelRefs: [...state.lastQueryHotelRefs] } : {}), ...(state.lastQueryOfferHotels ? { lastQueryOfferHotels: { ...state.lastQueryOfferHotels } } : {}), ...(state.terminal ? { terminal: { ...state.terminal, hotelRefs: [...state.terminal.hotelRefs] } } : {}) }
|
|
73
72
|
}
|
|
74
73
|
|
|
@@ -79,7 +78,7 @@ function isCriterion(value: unknown, valueCheck: (candidate: unknown) => boolean
|
|
|
79
78
|
function isMoney(value: unknown): boolean {
|
|
80
79
|
return isPlainRecord(value) && Object.keys(value).length === 3 && typeof value.amount === 'string' && Boolean(value.amount) && typeof value.currency === 'string' && Boolean(value.currency) && typeof value.sourceFactRef === 'string' && Boolean(value.sourceFactRef)
|
|
81
80
|
}
|
|
82
|
-
function
|
|
81
|
+
function isOfferCriteria(value: unknown): value is OfferCriteria {
|
|
83
82
|
if (!isPlainRecord(value)) return false
|
|
84
83
|
const allowed = ['roomType', 'bedType', 'meals', 'freeCancellation', 'freeCancellationUntil', 'totalPriceMax', 'roomsAvailableMin', 'payAtProperty', 'mobileRate', 'targetCount', 'sort']
|
|
85
84
|
if (Object.keys(value).some((key) => !allowed.includes(key))) return false
|
|
@@ -97,21 +96,21 @@ function isOfferCriteriaV2(value: unknown): value is OfferCriteriaV1 {
|
|
|
97
96
|
if (value.sort !== undefined && !['best_match', 'total_price_asc', 'cancellation_latest'].includes(value.sort as string)) return false
|
|
98
97
|
return true
|
|
99
98
|
}
|
|
100
|
-
function bindCriteria(next:
|
|
99
|
+
function bindCriteria(next: AvailabilityPolicyState, criteriaDigest: string, criteria?: OfferCriteria, required = false): void {
|
|
101
100
|
if (criteria !== undefined) {
|
|
102
|
-
if (!
|
|
101
|
+
if (!isOfferCriteria(criteria) || digest(criteria) !== criteriaDigest) throw new Error('availability_criteria_invalid')
|
|
103
102
|
if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest) throw new Error('availability_criteria_changed')
|
|
104
|
-
if (next.criteria &&
|
|
103
|
+
if (next.criteria && digest(next.criteria) !== criteriaDigest) throw new Error('availability_criteria_changed')
|
|
105
104
|
next.criteria = structuredClone(criteria); next.criteriaDigest = criteriaDigest
|
|
106
105
|
} else if (required && !next.criteria) throw new Error('availability_criteria_required')
|
|
107
|
-
else if (next.criteria && next.criteriaDigest !==
|
|
106
|
+
else if (next.criteria && next.criteriaDigest !== digest(next.criteria)) throw new Error('availability_criteria_mismatch')
|
|
108
107
|
else if (!next.criteria && criteriaDigest) {
|
|
109
108
|
if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest) throw new Error('availability_criteria_changed')
|
|
110
109
|
next.criteriaDigest = criteriaDigest
|
|
111
110
|
}
|
|
112
111
|
if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest && criteriaDigest) throw new Error('availability_criteria_changed')
|
|
113
112
|
}
|
|
114
|
-
function terminalFor(state:
|
|
113
|
+
function terminalFor(state: AvailabilityPolicyState): AvailabilityExhaustion | undefined {
|
|
115
114
|
if (!state.recoveryStarted || !state.hotelRefs.length) return undefined
|
|
116
115
|
const hotels = state.hotelRefs.map((ref) => state.hotels[ref]!)
|
|
117
116
|
if (hotels.some((hotel) => hotel.status === 'confirmed')) return { code: 'availability_confirmed', hotelRefs: [...state.hotelRefs], reason: 'confirmed', evidence: 'conclusive' }
|
|
@@ -119,19 +118,19 @@ function terminalFor(state: AvailabilityPolicyStateV2): AvailabilityExhaustionV2
|
|
|
119
118
|
// The first manual check starts with a workspace generation (zero recovery
|
|
120
119
|
// queries), so it must leave a fresh-query transition available.
|
|
121
120
|
if (hotels.some((hotel) =>
|
|
122
|
-
hotel.checksIssued <
|
|
121
|
+
hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL
|
|
123
122
|
&& (
|
|
124
123
|
hotel.status === 'active'
|
|
125
124
|
|| hotel.status === 'unvisited'
|
|
126
|
-
|| (hotel.freshOffersRequired && hotel.offerQueriesIssued <
|
|
125
|
+
|| (hotel.freshOffersRequired && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL)
|
|
127
126
|
)
|
|
128
127
|
)) return undefined
|
|
129
128
|
const conclusive = hotels.every((hotel) => hotel.status === 'negative')
|
|
130
129
|
return { code: conclusive ? 'availability_exhausted_complete' : 'availability_exhausted_inconclusive', hotelRefs: [...state.hotelRefs], reason: hotels.every((hotel) => !hotel.currentOfferRefs.length) ? 'no_current_offers' : 'check_limit_reached', evidence: conclusive ? 'conclusive' : 'inconclusive' }
|
|
131
130
|
}
|
|
132
|
-
function withTerminal(state:
|
|
133
|
-
function newHotel(hotelRef: string):
|
|
134
|
-
function phaseForActiveHotel(state:
|
|
131
|
+
function withTerminal(state: AvailabilityPolicyState): AvailabilityPolicyState { const terminal = terminalFor(state); return terminal ? { ...state, terminal, availabilityPhase: 'terminal' } : state }
|
|
132
|
+
function newHotel(hotelRef: string): AvailabilityHotelState { return { hotelRef, status: 'unvisited', checksIssued: 0, offerQueriesIssued: 0, generationNo: 0, generation: 0, currentOfferRefs: [], invalidatedOfferRefs: [], checkCount: 0, freshOffersRequired: false, tombstonedOfferRefs: [], tombstonedOfferVersionRefs: [], lastEvidence: 'none' } }
|
|
133
|
+
function phaseForActiveHotel(state: AvailabilityPolicyState): 'need_offers' | 'need_check' {
|
|
135
134
|
const active = state.hotels[state.hotelRefs[state.activeHotelOrdinal] ?? '']
|
|
136
135
|
return active?.status === 'active' && active.currentGeneration?.valid && active.currentOfferRefs.length > 0 && !active.freshOffersRequired
|
|
137
136
|
? 'need_check'
|
|
@@ -139,12 +138,12 @@ function phaseForActiveHotel(state: AvailabilityPolicyStateV2): 'need_offers' |
|
|
|
139
138
|
}
|
|
140
139
|
|
|
141
140
|
/** Initial workspace is only a hint; the bounded recovery starts at typed offers.query. */
|
|
142
|
-
export function
|
|
141
|
+
export function createAvailabilityPolicy(workspace: BookingWorkspaceSnapshot): AvailabilityPolicyState { assertWorkspaceLoadedOfferRefsUnique(workspace); return { initialized: false, recoveryStarted: false, availabilityPhase: 'need_offers', activeHotelOrdinal: 0, hotelRefs: [], hotels: {}, attempts: [], queryReservations: [] } }
|
|
143
142
|
/** Ordinary search results never get silently truncated into recovery candidates. */
|
|
144
|
-
export function
|
|
143
|
+
export function recordVisibleHotels(state: AvailabilityPolicyState, _workspace: BookingWorkspaceSnapshot): AvailabilityPolicyState { return copyState(state) }
|
|
145
144
|
/** Observe a normal offers query without opening an availability recovery. */
|
|
146
|
-
export function
|
|
147
|
-
if (workspace)
|
|
145
|
+
export function recordObservedOffersQuery(state: AvailabilityPolicyState, hotelRefs: readonly string[], criteriaDigest: string, workspace?: BookingWorkspaceSnapshot, receipt?: ActionReceipt, sourceActionId?: string, receiptDigest?: string, criteria?: OfferCriteria): AvailabilityPolicyState {
|
|
146
|
+
if (workspace) assertWorkspaceLoadedOfferRefsUnique(workspace)
|
|
148
147
|
if (state.recoveryStarted) return copyState(state)
|
|
149
148
|
const next = copyState(state)
|
|
150
149
|
if (!workspace || !receipt) return next
|
|
@@ -163,10 +162,10 @@ export function recordObservedOffersQueryV2(state: AvailabilityPolicyStateV2, ho
|
|
|
163
162
|
delete next.criteria
|
|
164
163
|
delete next.criteriaDigest
|
|
165
164
|
bindCriteria(next, criteriaDigest, criteria)
|
|
166
|
-
if (!sourceActionId || sourceActionId !== receipt.actionId || !receiptDigest || receiptDigest !==
|
|
165
|
+
if (!sourceActionId || sourceActionId !== receipt.actionId || !receiptDigest || receiptDigest !== digest(receipt)) throw new Error('availability_query_provenance_mismatch')
|
|
167
166
|
next.lastQueryHotelRefs = [...new Set(hotelRefs)]
|
|
168
167
|
next.lastQueryCriteriaDigest = criteriaDigest || undefined
|
|
169
|
-
next.lastQueryOfferHotels =
|
|
168
|
+
next.lastQueryOfferHotels = validateOffersReceiptWorkspace(receipt, workspace, hotelRefs)
|
|
170
169
|
next.lastQuerySourceActionId = sourceActionId
|
|
171
170
|
next.lastQuerySourceReceiptDigest = receiptDigest
|
|
172
171
|
next.lastQueryWorkspaceRevision = workspace.revision
|
|
@@ -186,8 +185,8 @@ export function recordObservedOffersQueryV2(state: AvailabilityPolicyStateV2, ho
|
|
|
186
185
|
* the authoritative loaded offers; partial/gap receipts may only describe an
|
|
187
186
|
* evidenced subset. An unreported offer is never silently admitted.
|
|
188
187
|
*/
|
|
189
|
-
export function
|
|
190
|
-
|
|
188
|
+
export function validateOffersReceiptWorkspace(receipt: ActionReceipt, workspace: BookingWorkspaceSnapshot, hotelRefs: readonly string[]): Record<string, string> {
|
|
189
|
+
assertWorkspaceLoadedOfferRefsUnique(workspace)
|
|
191
190
|
if (receipt.observation.kind !== 'offers.state') throw new Error('availability_offers_observation_required')
|
|
192
191
|
const requested = unique(hotelRefs)
|
|
193
192
|
if (!sameSet(receipt.observation.hotelRefs, requested)) throw new Error('availability_offers_hotel_mismatch')
|
|
@@ -210,13 +209,13 @@ export function validateOffersReceiptWorkspaceV2(receipt: ActionReceiptV2, works
|
|
|
210
209
|
function sameSet(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && unique(a).sort().every((value, index) => value === unique(b).sort()[index]) }
|
|
211
210
|
|
|
212
211
|
/** A typed offers.query receipt starts/advances a bounded candidate recovery. */
|
|
213
|
-
export function
|
|
214
|
-
|
|
212
|
+
export function recordOffersQueryIssued(state: AvailabilityPolicyState, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshot, actionId: string, criteriaDigest = '', criteria?: OfferCriteria): AvailabilityPolicyState {
|
|
213
|
+
assertWorkspaceLoadedOfferRefsUnique(workspace)
|
|
215
214
|
const next = copyState(state); const requested = unique(hotelRefs)
|
|
216
|
-
if (!requested.length || requested.length >
|
|
215
|
+
if (!requested.length || requested.length > MAX_HOTELS_PER_TASK) throw new Error('availability_hotel_limit_exceeded')
|
|
217
216
|
if (!state.recoveryStarted) throw new Error('availability_recovery_not_started')
|
|
218
217
|
if (state.terminal) {
|
|
219
|
-
if (requested.some((ref) => state.hotels[ref]?.offerQueriesIssued >=
|
|
218
|
+
if (requested.some((ref) => state.hotels[ref]?.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL)) throw new Error('availability_offer_query_limit_reached')
|
|
220
219
|
throw new Error('availability_terminal')
|
|
221
220
|
}
|
|
222
221
|
if (requested.some((ref) => !next.hotels[ref])) throw new Error('availability_hotel_unknown')
|
|
@@ -227,23 +226,23 @@ export function recordOffersQueryIssuedV2(state: AvailabilityPolicyStateV2, hote
|
|
|
227
226
|
if (!sameSet(existingReservation.hotelRefs, requested) || existingReservation.workspaceRevision !== workspace.revision) throw new Error('availability_query_action_conflict')
|
|
228
227
|
return next
|
|
229
228
|
}
|
|
230
|
-
for (const hotelRef of requested) { const hotel = next.hotels[hotelRef]!; if (hotel.offerQueriesIssued >=
|
|
229
|
+
for (const hotelRef of requested) { const hotel = next.hotels[hotelRef]!; if (hotel.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL) throw new Error('availability_offer_query_limit_reached'); hotel.offerQueriesIssued += 1 }
|
|
231
230
|
next.queryReservations.push({ actionId, hotelRefs: [...requested], workspaceRevision: workspace.revision }); next.availabilityPhase = 'waiting_offers'; next.initialized = true; return next
|
|
232
231
|
}
|
|
233
|
-
export function
|
|
234
|
-
|
|
232
|
+
export function recordOffersGeneration(state: AvailabilityPolicyState, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshot, actionId: string, receiptDigest: string, receipt: ActionReceipt, criteriaDigest = '', criteria?: OfferCriteria): AvailabilityPolicyState {
|
|
233
|
+
assertWorkspaceLoadedOfferRefsUnique(workspace)
|
|
235
234
|
if (!state.recoveryStarted) return copyState(state)
|
|
236
235
|
if (state.terminal) throw new Error('availability_terminal')
|
|
237
236
|
const next = copyState(state); const requested = unique(hotelRefs)
|
|
238
|
-
if (!requested.length || requested.length >
|
|
237
|
+
if (!requested.length || requested.length > MAX_HOTELS_PER_TASK) throw new Error('availability_hotel_limit_exceeded')
|
|
239
238
|
const reservation = next.queryReservations.find((query) => query.actionId === actionId)
|
|
240
239
|
if (!reservation || !sameSet(reservation.hotelRefs, requested)) throw new Error('availability_query_not_reserved')
|
|
241
240
|
if (receipt.actionId !== actionId) throw new Error('availability_query_receipt_mismatch')
|
|
242
|
-
if (receiptDigest !==
|
|
241
|
+
if (receiptDigest !== digest(receipt)) throw new Error('availability_receipt_digest_mismatch')
|
|
243
242
|
if (requested.some((ref) => !next.hotels[ref])) throw new Error('availability_hotel_unknown')
|
|
244
243
|
else if ((requested.length !== 1 || next.hotelRefs[next.activeHotelOrdinal] !== requested[0]) && (next.attempts.length > 0 || Object.values(next.hotels).some((hotel) => hotel.generationNo > 0))) throw new Error('availability_hotel_not_active')
|
|
245
244
|
bindCriteria(next, criteriaDigest, criteria)
|
|
246
|
-
if (receipt.observation.kind === 'offers.state')
|
|
245
|
+
if (receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspace(receipt, workspace, requested)
|
|
247
246
|
for (const hotelRef of requested) {
|
|
248
247
|
const hotel = next.hotels[hotelRef]!
|
|
249
248
|
const partial = receipt.status === 'changed' || receipt.status === 'partial' || receipt.status === 'failed' || receipt.status === 'stale' || receipt.resultContract.outcome === 'partial' || (receipt.resultContract.outcome === 'complete' && !receipt.resultContract.hardCriteriaMet) || Boolean(receipt.resultContract.blockers.length) || Boolean(receipt.resultContract.gapCodes.length) || Boolean(receipt.observation.kind === 'gap' || (receipt.observation.kind === 'offers.state' && receipt.observation.gapCodes?.length))
|
|
@@ -254,11 +253,11 @@ export function recordOffersGenerationV2(state: AvailabilityPolicyStateV2, hotel
|
|
|
254
253
|
: offerRefsForHotel(workspace, hotelRef)
|
|
255
254
|
const refs = legalOfferRefsForHotel(workspace, hotel, candidateRefs)
|
|
256
255
|
const unusable = ['stale', 'failed', 'unsupported'].includes(receipt.status)
|
|
257
|
-
if (unusable) { hotel.currentOfferRefs = []; hotel.invalidatedOfferRefs = [...(hotel.currentGeneration?.orderedOfferRefs ?? [])]; hotel.freshOffersRequired = hotel.checksIssued <
|
|
256
|
+
if (unusable) { hotel.currentOfferRefs = []; hotel.invalidatedOfferRefs = [...(hotel.currentGeneration?.orderedOfferRefs ?? [])]; hotel.freshOffersRequired = hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL; hotel.status = 'inconclusive'; hotel.lastEvidence = 'inconclusive'; continue }
|
|
258
257
|
const filteredAllCandidates = candidateRefs.length > 0 && refs.length === 0
|
|
259
|
-
const noLegalRefsNeedsRefresh = filteredAllCandidates && hotel.offerQueriesIssued <
|
|
260
|
-
hotel.generationNo += 1; hotel.generation = hotel.generationNo; hotel.currentOfferRefs = partial || noLegalRefsNeedsRefresh ? [] : refs; hotel.invalidatedOfferRefs = partial || noLegalRefsNeedsRefresh ? candidateRefs : []; hotel.freshOffersRequired = (partial || noLegalRefsNeedsRefresh) && hotel.checksIssued <
|
|
261
|
-
hotel.currentGeneration = { generationId: `${hotelRef}:generation:${hotel.generationNo}`, source: { kind: 'query_receipt', actionId, receiptDigest, workspaceRevision: workspace.revision }, offerSetDigest:
|
|
258
|
+
const noLegalRefsNeedsRefresh = filteredAllCandidates && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL
|
|
259
|
+
hotel.generationNo += 1; hotel.generation = hotel.generationNo; hotel.currentOfferRefs = partial || noLegalRefsNeedsRefresh ? [] : refs; hotel.invalidatedOfferRefs = partial || noLegalRefsNeedsRefresh ? candidateRefs : []; hotel.freshOffersRequired = (partial || noLegalRefsNeedsRefresh) && hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL; hotel.status = partial || noLegalRefsNeedsRefresh ? 'inconclusive' : refs.length ? 'active' : 'negative'; hotel.lastEvidence = partial || noLegalRefsNeedsRefresh ? 'inconclusive' : refs.length ? 'none' : 'unavailable'
|
|
260
|
+
hotel.currentGeneration = { generationId: `${hotelRef}:generation:${hotel.generationNo}`, source: { kind: 'query_receipt', actionId, receiptDigest, workspaceRevision: workspace.revision }, offerSetDigest: digest(refs), orderedOfferRefs: refs, evidence: partial ? 'partial' : 'complete', valid: refs.length > 0 && !partial }
|
|
262
261
|
}
|
|
263
262
|
const firstRequested = next.hotelRefs.indexOf(requested[0]!)
|
|
264
263
|
if (firstRequested >= 0) next.activeHotelOrdinal = firstRequested
|
|
@@ -271,8 +270,8 @@ export function recordOffersGenerationV2(state: AvailabilityPolicyStateV2, hotel
|
|
|
271
270
|
next.initialized = true; next.availabilityPhase = phaseForActiveHotel(next); delete next.terminal; return withTerminal(next)
|
|
272
271
|
}
|
|
273
272
|
|
|
274
|
-
export function
|
|
275
|
-
|
|
273
|
+
export function canIssueOfferCheck(state: AvailabilityPolicyState, workspace: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string): AvailabilityPolicyDecision {
|
|
274
|
+
assertWorkspaceLoadedOfferRefsUnique(workspace)
|
|
276
275
|
// Availability recovery is opt-in at the typed offers.query seam. Existing
|
|
277
276
|
// v2 read flows retain their pre-recovery offer.check behavior.
|
|
278
277
|
if (!state.recoveryStarted) {
|
|
@@ -288,12 +287,12 @@ export function canIssueOfferCheckV2(state: AvailabilityPolicyStateV2, workspace
|
|
|
288
287
|
if (hotel.freshOffersRequired) return { ok: false, code: 'offers_refresh_required' }
|
|
289
288
|
if (hotel.tombstonedOfferRefs.includes(offerRef) || hotel.tombstonedOfferVersionRefs.includes(offerVersionRef)) return { ok: false, code: 'generation_invalid' }
|
|
290
289
|
if (!hotel.currentGeneration?.valid || !hotel.currentGeneration.orderedOfferRefs.includes(offerRef) || hotel.invalidatedOfferRefs.includes(offerRef)) return { ok: false, code: 'generation_invalid' }
|
|
291
|
-
if (hotel.checksIssued >=
|
|
290
|
+
if (hotel.checksIssued >= MAX_OFFER_CHECKS_PER_HOTEL) return { ok: false, code: 'hotel_check_limit_reached' }
|
|
292
291
|
return { ok: true, hotelRef: loaded.hotelRef, offerRef, offerVersionRef, checkCount: hotel.checksIssued + 1, generation: hotel.generationNo }
|
|
293
292
|
}
|
|
294
293
|
/** Start the bounded recovery epoch at the first typed offer.check. */
|
|
295
|
-
function
|
|
296
|
-
|
|
294
|
+
function startRecoveryAtOffer(state: AvailabilityPolicyState, workspace: BookingWorkspaceSnapshot, offerRef: string, actionId: string): AvailabilityPolicyState {
|
|
295
|
+
assertWorkspaceLoadedOfferRefsUnique(workspace)
|
|
297
296
|
const loaded = workspace.loadedOffers.find((offer) => offer.offerRef === offerRef)
|
|
298
297
|
if (!loaded) return copyState(state)
|
|
299
298
|
const shortlistHotels = workspace.shortlistedOfferRefs.map((ref) => workspace.loadedOffers.find((offer) => offer.offerRef === ref)?.hotelRef ?? state.lastQueryOfferHotels?.[ref]).map((ref, index) => {
|
|
@@ -301,8 +300,8 @@ function startRecoveryAtOfferV2(state: AvailabilityPolicyStateV2, workspace: Boo
|
|
|
301
300
|
return ref
|
|
302
301
|
})
|
|
303
302
|
const fallbackHotels = shortlistHotels.length ? shortlistHotels : []
|
|
304
|
-
const candidates = unique([loaded.hotelRef, ...fallbackHotels]).slice(0,
|
|
305
|
-
const next = copyState(state); next.recoveryStarted = true; next.recoveryId = `recovery:${actionId}`; next.hotelRefs = candidates; next.candidateSetDigest =
|
|
303
|
+
const candidates = unique([loaded.hotelRef, ...fallbackHotels]).slice(0, MAX_HOTELS_PER_TASK)
|
|
304
|
+
const next = copyState(state); next.recoveryStarted = true; next.recoveryId = `recovery:${actionId}`; next.hotelRefs = candidates; next.candidateSetDigest = digest(candidates); next.hotels = Object.fromEntries(candidates.map((ref) => [ref, newHotel(ref)]))
|
|
306
305
|
const nextHotel = next.hotels[loaded.hotelRef]!
|
|
307
306
|
const mappedRefs = state.lastQueryOfferHotels ? Object.entries(state.lastQueryOfferHotels).filter(([, hotel]) => hotel === loaded.hotelRef).map(([ref]) => ref) : []
|
|
308
307
|
const currentRefs = unique(workspace.loadedOffers.filter((offer) => offer.hotelRef === loaded.hotelRef).map((offer) => offer.offerRef))
|
|
@@ -310,24 +309,24 @@ function startRecoveryAtOfferV2(state: AvailabilityPolicyStateV2, workspace: Boo
|
|
|
310
309
|
&& state.lastQueryWorkspaceRevision === workspace.revision
|
|
311
310
|
&& Boolean(state.lastQuerySourceActionId && state.lastQuerySourceReceiptDigest)
|
|
312
311
|
&& sameSet(mappedRefs, currentRefs)
|
|
313
|
-
if (mapped && state.criteria) { next.criteria = structuredClone(state.criteria); next.criteriaDigest =
|
|
312
|
+
if (mapped && state.criteria) { next.criteria = structuredClone(state.criteria); next.criteriaDigest = digest(next.criteria) }
|
|
314
313
|
const refs = mapped && state.lastQueryOfferHotels
|
|
315
|
-
? unique([offerRef, ...mappedRefs]).slice(0,
|
|
314
|
+
? unique([offerRef, ...mappedRefs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION)
|
|
316
315
|
: offerRefsForHotel(workspace, loaded.hotelRef, offerRef)
|
|
317
316
|
const source = mapped
|
|
318
317
|
? { kind: 'query_receipt' as const, actionId: state.lastQuerySourceActionId!, receiptDigest: state.lastQuerySourceReceiptDigest!, workspaceRevision: state.lastQueryWorkspaceRevision! }
|
|
319
|
-
: { kind: 'workspace_snapshot' as const, workspaceDigest:
|
|
320
|
-
nextHotel.generationNo = 1; nextHotel.generation = 1; nextHotel.currentOfferRefs = refs; nextHotel.status = refs.length ? 'active' : 'inconclusive'; nextHotel.currentGeneration = { generationId: `${loaded.hotelRef}:generation:1`, source, offerSetDigest:
|
|
318
|
+
: { kind: 'workspace_snapshot' as const, workspaceDigest: workspaceDigest(workspace), workspaceRevision: workspace.revision }
|
|
319
|
+
nextHotel.generationNo = 1; nextHotel.generation = 1; nextHotel.currentOfferRefs = refs; nextHotel.status = refs.length ? 'active' : 'inconclusive'; nextHotel.currentGeneration = { generationId: `${loaded.hotelRef}:generation:1`, source, offerSetDigest: digest(refs), orderedOfferRefs: refs, evidence: 'complete', valid: refs.length > 0 }; next.initialized = true; next.availabilityPhase = 'need_check'; return next
|
|
321
320
|
}
|
|
322
|
-
export function
|
|
323
|
-
|
|
321
|
+
export function recordOfferCheckIssued(state: AvailabilityPolicyState, workspace: BookingWorkspaceSnapshot, offerRef: string, offerVersionRef: string, actionId = `offer-check-${offerRef}-${state.attempts.length + 1}`): AvailabilityPolicyState {
|
|
322
|
+
assertWorkspaceLoadedOfferRefsUnique(workspace)
|
|
324
323
|
if (state.terminal) throw new Error('availability_terminal')
|
|
325
324
|
if (!state.recoveryStarted) {
|
|
326
325
|
if (!loadedOfferVersion(workspace, offerRef, offerVersionRef)) throw new Error('availability_offer_not_loaded')
|
|
327
|
-
const started =
|
|
328
|
-
return
|
|
326
|
+
const started = startRecoveryAtOffer(state, workspace, offerRef, actionId)
|
|
327
|
+
return recordOfferCheckIssued(started, workspace, offerRef, offerVersionRef, actionId)
|
|
329
328
|
}
|
|
330
|
-
const decision =
|
|
329
|
+
const decision = canIssueOfferCheck(state, workspace, offerRef, offerVersionRef); if (!decision.ok) throw new Error(`availability_${decision.code}`)
|
|
331
330
|
const next = copyState(state); const hotel = next.hotels[decision.hotelRef]!; hotel.checksIssued += 1; hotel.checkCount = hotel.checksIssued; next.attempts.push({ actionId, hotelRef: decision.hotelRef, offerRef, offerVersionRef, generation: hotel.generationNo, ordinal: hotel.checksIssued, workspaceRevision: workspace.revision }); hotel.currentGeneration!.valid = false; next.availabilityPhase = 'waiting_check'; return next
|
|
332
331
|
}
|
|
333
332
|
|
|
@@ -336,27 +335,27 @@ export function recordOfferCheckIssuedV2(state: AvailabilityPolicyStateV2, works
|
|
|
336
335
|
* next to the receipt reducers makes ledger replay use precisely the same
|
|
337
336
|
* transition as the write path.
|
|
338
337
|
*/
|
|
339
|
-
export function
|
|
340
|
-
state:
|
|
341
|
-
workspace:
|
|
342
|
-
action:
|
|
343
|
-
):
|
|
338
|
+
export function reduceAvailabilityAction(
|
|
339
|
+
state: AvailabilityPolicyState,
|
|
340
|
+
workspace: BookingWorkspaceSnapshot,
|
|
341
|
+
action: AvailabilityActionLike,
|
|
342
|
+
): AvailabilityPolicyState {
|
|
344
343
|
if (action.kind === 'offers.query') {
|
|
345
|
-
if (!
|
|
346
|
-
if (!
|
|
347
|
-
if (!
|
|
348
|
-
const criteriaDigest =
|
|
349
|
-
let next =
|
|
350
|
-
if (state.recoveryStarted) next =
|
|
344
|
+
if (!isOfferCriteria(action.input.criteria) && (!state.criteria || !isOfferCriteria(state.criteria))) throw new Error('availability_criteria_required')
|
|
345
|
+
if (!isOfferCriteria(action.input.criteria) && state.recoveryStarted) throw new Error('availability_criteria_required')
|
|
346
|
+
if (!isOfferCriteria(action.input.criteria)) throw new Error('availability_criteria_required')
|
|
347
|
+
const criteriaDigest = digest(action.input.criteria)
|
|
348
|
+
let next = recordObservedOffersQuery(state, action.input.hotelRefs, criteriaDigest)
|
|
349
|
+
if (state.recoveryStarted) next = recordOffersQueryIssued(next, action.input.hotelRefs, workspace, action.actionId, criteriaDigest, action.input.criteria)
|
|
351
350
|
return next
|
|
352
351
|
}
|
|
353
|
-
if (action.kind === 'offer.check') return
|
|
352
|
+
if (action.kind === 'offer.check') return recordOfferCheckIssued(state, workspace, action.input.offerRef, action.input.offerVersionRef, action.actionId)
|
|
354
353
|
return copyState(state)
|
|
355
354
|
}
|
|
356
355
|
|
|
357
356
|
/** Fold a receipt against the exact durable attempt. Non-confirming results invalidate the whole generation. */
|
|
358
|
-
export function
|
|
359
|
-
|
|
357
|
+
export function recordOfferCheckReceipt(state: AvailabilityPolicyState, workspace: BookingWorkspaceSnapshot, receipt: ActionReceipt, actionId: string, offerRef: string, offerVersionRef: string, expectedRevision: number): AvailabilityPolicyState {
|
|
358
|
+
assertWorkspaceLoadedOfferRefsUnique(workspace)
|
|
360
359
|
if (!state.recoveryStarted) return copyState(state)
|
|
361
360
|
const attempt = state.attempts.find((candidate) => candidate.actionId === actionId); if (!attempt) throw new Error('availability_attempt_unknown')
|
|
362
361
|
if (receipt.actionId !== actionId) throw new Error('availability_attempt_mismatch')
|
|
@@ -381,7 +380,7 @@ export function recordOfferCheckReceiptV2(state: AvailabilityPolicyStateV2, work
|
|
|
381
380
|
hotel.tombstonedOfferVersionRefs = unique([...hotel.tombstonedOfferVersionRefs, offerVersionRef])
|
|
382
381
|
hotel.tombstonedOfferRefs = unique([...hotel.tombstonedOfferRefs, offerRef])
|
|
383
382
|
}
|
|
384
|
-
hotel.currentGeneration.valid = false; hotel.invalidatedOfferRefs = [...hotel.currentGeneration.orderedOfferRefs]; hotel.currentOfferRefs = []; hotel.freshOffersRequired = hotel.checksIssued <
|
|
383
|
+
hotel.currentGeneration.valid = false; hotel.invalidatedOfferRefs = [...hotel.currentGeneration.orderedOfferRefs]; hotel.currentOfferRefs = []; hotel.freshOffersRequired = hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL
|
|
385
384
|
hotel.status = completeNegative ? 'negative' : 'inconclusive'; hotel.lastEvidence = completeNegative ? 'unavailable' : 'inconclusive'
|
|
386
385
|
const currentOrdinal = next.hotelRefs.indexOf(attempt.hotelRef)
|
|
387
386
|
const nextOrdinal = hotel.freshOffersRequired ? currentOrdinal : next.hotelRefs.findIndex((ref) => ['active', 'unvisited'].includes(next.hotels[ref]!.status))
|
|
@@ -391,35 +390,35 @@ export function recordOfferCheckReceiptV2(state: AvailabilityPolicyStateV2, work
|
|
|
391
390
|
|
|
392
391
|
/** Fold the availability portion of a receipt using the same pure reducer as
|
|
393
392
|
* continueWithReceipt and resumeTask. */
|
|
394
|
-
export function
|
|
395
|
-
state:
|
|
396
|
-
workspace:
|
|
397
|
-
receipt:
|
|
398
|
-
action:
|
|
399
|
-
):
|
|
400
|
-
let next =
|
|
393
|
+
export function reduceAvailabilityReceipt(
|
|
394
|
+
state: AvailabilityPolicyState,
|
|
395
|
+
workspace: BookingWorkspaceSnapshot,
|
|
396
|
+
receipt: ActionReceipt,
|
|
397
|
+
action: AvailabilityActionLike,
|
|
398
|
+
): AvailabilityPolicyState {
|
|
399
|
+
let next = recordVisibleHotels(state, workspace)
|
|
401
400
|
if (action.kind === 'offers.query' && !state.recoveryStarted) {
|
|
402
|
-
if (!
|
|
403
|
-
next =
|
|
401
|
+
if (!isOfferCriteria(action.input.criteria)) throw new Error('availability_criteria_required')
|
|
402
|
+
next = recordObservedOffersQuery(next, action.input.hotelRefs, digest(action.input.criteria), workspace, receipt, action.actionId, digest(receipt), action.input.criteria)
|
|
404
403
|
} else if (action.kind === 'offers.query' && state.recoveryStarted) {
|
|
405
|
-
if (!
|
|
406
|
-
next =
|
|
404
|
+
if (!isOfferCriteria(action.input.criteria)) throw new Error('availability_criteria_required')
|
|
405
|
+
next = recordOffersGeneration(next, action.input.hotelRefs, workspace, action.actionId, digest(receipt), receipt, state.criteriaDigest ?? '', action.input.criteria)
|
|
407
406
|
} else if (action.kind === 'offer.check') {
|
|
408
407
|
if (action.expectedRevision === undefined) throw new Error('availability_expected_revision_missing')
|
|
409
|
-
next =
|
|
408
|
+
next = recordOfferCheckReceipt(next, workspace, receipt, action.actionId, action.input.offerRef, action.input.offerVersionRef, action.expectedRevision)
|
|
410
409
|
}
|
|
411
410
|
return next
|
|
412
411
|
}
|
|
413
|
-
export function
|
|
414
|
-
export function
|
|
415
|
-
export function
|
|
412
|
+
export function availabilityPolicyIsTerminal(state: AvailabilityPolicyState): boolean { return Boolean(state.terminal) }
|
|
413
|
+
export function availabilityPolicyResult(state: AvailabilityPolicyState): AvailabilityExhaustion | undefined { return state.terminal ? { ...state.terminal, hotelRefs: [...state.terminal.hotelRefs] } : undefined }
|
|
414
|
+
export function validateAvailabilityPolicy(state: AvailabilityPolicyState): boolean {
|
|
416
415
|
try {
|
|
417
416
|
if (!state || typeof state.initialized !== 'boolean' || typeof state.recoveryStarted !== 'boolean') return false
|
|
418
417
|
if (state.recoveryId !== undefined && (typeof state.recoveryId !== 'string' || !state.recoveryId)) return false
|
|
419
418
|
if (state.criteriaDigest !== undefined && (typeof state.criteriaDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.criteriaDigest))) return false
|
|
420
419
|
if (state.candidateSetDigest !== undefined && (typeof state.candidateSetDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.candidateSetDigest))) return false
|
|
421
420
|
if (!['need_offers', 'waiting_offers', 'need_check', 'waiting_check', 'terminal'].includes(state.availabilityPhase)) return false
|
|
422
|
-
if (!Number.isSafeInteger(state.activeHotelOrdinal) || !Array.isArray(state.hotelRefs) || state.hotelRefs.length >
|
|
421
|
+
if (!Number.isSafeInteger(state.activeHotelOrdinal) || !Array.isArray(state.hotelRefs) || state.hotelRefs.length > MAX_HOTELS_PER_TASK) return false
|
|
423
422
|
if (state.hotelRefs.some((ref) => typeof ref !== 'string' || !ref) || new Set(state.hotelRefs).size !== state.hotelRefs.length) return false
|
|
424
423
|
if ((state.hotelRefs.length && (state.activeHotelOrdinal < 0 || state.activeHotelOrdinal >= state.hotelRefs.length)) || (!state.hotelRefs.length && state.activeHotelOrdinal !== 0)) return false
|
|
425
424
|
if (!state.hotels || typeof state.hotels !== 'object' || Object.keys(state.hotels).sort().join('\0') !== [...state.hotelRefs].sort().join('\0')) return false
|
|
@@ -427,7 +426,7 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
|
|
|
427
426
|
if (!state.recoveryStarted) {
|
|
428
427
|
if (state.recoveryId !== undefined || state.candidateSetDigest !== undefined || state.hotelRefs.length || Object.keys(state.hotels).length || state.attempts.length || state.queryReservations.length || state.terminal !== undefined) return false
|
|
429
428
|
if (state.criteria !== undefined || state.criteriaDigest !== undefined) {
|
|
430
|
-
if (!
|
|
429
|
+
if (!isOfferCriteria(state.criteria) || !state.criteriaDigest || state.criteriaDigest !== digest(state.criteria)) return false
|
|
431
430
|
}
|
|
432
431
|
if (state.lastQueryCriteriaDigest !== undefined && state.lastQueryCriteriaDigest !== state.criteriaDigest) return false
|
|
433
432
|
if (state.lastQueryHotelRefs !== undefined && (!Array.isArray(state.lastQueryHotelRefs) || new Set(state.lastQueryHotelRefs).size !== state.lastQueryHotelRefs.length || state.lastQueryHotelRefs.some((ref) => typeof ref !== 'string' || !ref))) return false
|
|
@@ -436,8 +435,8 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
|
|
|
436
435
|
if (state.lastQuerySourceReceiptDigest !== undefined && state.lastQuerySourceActionId === undefined) return false
|
|
437
436
|
return state.initialized === false && state.availabilityPhase === 'need_offers'
|
|
438
437
|
}
|
|
439
|
-
if (!state.initialized || !state.recoveryId || !state.candidateSetDigest || state.candidateSetDigest !==
|
|
440
|
-
const criteriaBound =
|
|
438
|
+
if (!state.initialized || !state.recoveryId || !state.candidateSetDigest || state.candidateSetDigest !== digest(state.hotelRefs)) return false
|
|
439
|
+
const criteriaBound = isOfferCriteria(state.criteria) && Boolean(state.criteriaDigest) && state.criteriaDigest === digest(state.criteria)
|
|
441
440
|
const manualCheckWorkspaceSnapshot = state.attempts.length > 0 && state.attempts.every((attempt) => state.hotels[attempt.hotelRef]?.currentGeneration?.source.kind === 'workspace_snapshot')
|
|
442
441
|
const manualCheckMayBindCriteria = !state.criteria && !state.criteriaDigest && state.queryReservations.length === 0 && manualCheckWorkspaceSnapshot && (
|
|
443
442
|
['waiting_check', 'need_offers'].includes(state.availabilityPhase)
|
|
@@ -451,29 +450,29 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
|
|
|
451
450
|
if (state.lastQuerySourceActionId !== undefined && (typeof state.lastQuerySourceActionId !== 'string' || !state.lastQuerySourceActionId || typeof state.lastQuerySourceReceiptDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.lastQuerySourceReceiptDigest) || !Number.isSafeInteger(state.lastQueryWorkspaceRevision) || state.lastQueryWorkspaceRevision! < 0)) return false
|
|
452
451
|
if (state.lastQuerySourceReceiptDigest !== undefined && state.lastQuerySourceActionId === undefined) return false
|
|
453
452
|
if (state.hotelRefs.length === 0) return false
|
|
454
|
-
const validStatus = new Set<
|
|
455
|
-
const validEvidence = new Set<
|
|
453
|
+
const validStatus = new Set<AvailabilityHotelStatus>(['unvisited', 'active', 'negative', 'confirmed', 'inconclusive'])
|
|
454
|
+
const validEvidence = new Set<AvailabilityHotelState['lastEvidence']>(['none', 'confirmed', 'unavailable', 'inconclusive'])
|
|
456
455
|
for (const ref of state.hotelRefs) {
|
|
457
456
|
const hotel = state.hotels[ref]
|
|
458
457
|
if (!hotel || hotel.hotelRef !== ref || !validStatus.has(hotel.status) || !validEvidence.has(hotel.lastEvidence)) return false
|
|
459
458
|
if (![hotel.checksIssued, hotel.offerQueriesIssued, hotel.generationNo, hotel.generation, hotel.checkCount].every(Number.isSafeInteger)) return false
|
|
460
|
-
if (hotel.checksIssued < 0 || hotel.checksIssued >
|
|
459
|
+
if (hotel.checksIssued < 0 || hotel.checksIssued > MAX_OFFER_CHECKS_PER_HOTEL || hotel.offerQueriesIssued < 0 || hotel.offerQueriesIssued > MAX_OFFER_QUERIES_PER_HOTEL || hotel.generationNo < 0 || hotel.generation !== hotel.generationNo || hotel.checkCount !== hotel.checksIssued) return false
|
|
461
460
|
if (typeof hotel.freshOffersRequired !== 'boolean' || !Array.isArray(hotel.currentOfferRefs) || !Array.isArray(hotel.invalidatedOfferRefs) || !Array.isArray(hotel.tombstonedOfferRefs) || !Array.isArray(hotel.tombstonedOfferVersionRefs)) return false
|
|
462
461
|
if (hotel.currentOfferRefs.some((offer) => typeof offer !== 'string' || !offer) || hotel.invalidatedOfferRefs.some((offer) => typeof offer !== 'string' || !offer) || hotel.tombstonedOfferRefs.some((offer) => typeof offer !== 'string' || !offer) || hotel.tombstonedOfferVersionRefs.some((offer) => typeof offer !== 'string' || !offer)) return false
|
|
463
|
-
if (new Set(hotel.currentOfferRefs).size !== hotel.currentOfferRefs.length || new Set(hotel.invalidatedOfferRefs).size !== hotel.invalidatedOfferRefs.length || new Set(hotel.tombstonedOfferRefs).size !== hotel.tombstonedOfferRefs.length || new Set(hotel.tombstonedOfferVersionRefs).size !== hotel.tombstonedOfferVersionRefs.length || hotel.currentOfferRefs.length >
|
|
462
|
+
if (new Set(hotel.currentOfferRefs).size !== hotel.currentOfferRefs.length || new Set(hotel.invalidatedOfferRefs).size !== hotel.invalidatedOfferRefs.length || new Set(hotel.tombstonedOfferRefs).size !== hotel.tombstonedOfferRefs.length || new Set(hotel.tombstonedOfferVersionRefs).size !== hotel.tombstonedOfferVersionRefs.length || hotel.currentOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION || hotel.invalidatedOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION || hotel.currentOfferRefs.some((offer) => hotel.invalidatedOfferRefs.includes(offer) || hotel.tombstonedOfferRefs.includes(offer))) return false
|
|
464
463
|
const generation = hotel.currentGeneration
|
|
465
464
|
if (hotel.generationNo === 0) {
|
|
466
465
|
if (generation !== undefined || hotel.currentOfferRefs.length || hotel.invalidatedOfferRefs.length || hotel.tombstonedOfferRefs.length || hotel.tombstonedOfferVersionRefs.length || hotel.status !== 'unvisited' || hotel.lastEvidence !== 'none' || hotel.checksIssued !== 0 || hotel.offerQueriesIssued !== 0 || hotel.freshOffersRequired) return false
|
|
467
466
|
continue
|
|
468
467
|
}
|
|
469
|
-
if (!generation || generation.generationId !== `${ref}:generation:${hotel.generationNo}` || !/^[0-9a-f]{64}$/.test(generation.offerSetDigest) || generation.offerSetDigest !==
|
|
470
|
-
if (generation.offerSetDigest !==
|
|
468
|
+
if (!generation || generation.generationId !== `${ref}:generation:${hotel.generationNo}` || !/^[0-9a-f]{64}$/.test(generation.offerSetDigest) || generation.offerSetDigest !== digest(generation.orderedOfferRefs) || !Array.isArray(generation.orderedOfferRefs) || generation.orderedOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION || generation.orderedOfferRefs.some((offer) => typeof offer !== 'string' || !offer) || new Set(generation.orderedOfferRefs).size !== generation.orderedOfferRefs.length || !['complete', 'partial'].includes(generation.evidence) || typeof generation.valid !== 'boolean') return false
|
|
469
|
+
if (generation.offerSetDigest !== digest(generation.orderedOfferRefs) || hotel.currentOfferRefs.some((offer) => !generation.orderedOfferRefs.includes(offer)) || hotel.invalidatedOfferRefs.some((offer) => !generation.orderedOfferRefs.includes(offer))) return false
|
|
471
470
|
const source = generation.source
|
|
472
471
|
if (!source || !['query_receipt', 'workspace_snapshot'].includes(source.kind) || !Number.isSafeInteger(source.workspaceRevision) || source.workspaceRevision < 0) return false
|
|
473
472
|
if (source.kind === 'query_receipt' && (typeof source.actionId !== 'string' || !source.actionId || !/^[0-9a-f]{64}$/.test(source.receiptDigest))) return false
|
|
474
473
|
if (source.kind === 'workspace_snapshot' && (typeof source.workspaceDigest !== 'string' || !/^[0-9a-f]{64}$/.test(source.workspaceDigest))) return false
|
|
475
474
|
if (source.kind === 'query_receipt' && state.lastQuerySourceActionId === source.actionId && (state.lastQuerySourceReceiptDigest !== source.receiptDigest || state.lastQueryWorkspaceRevision !== source.workspaceRevision)) return false
|
|
476
|
-
if (hotel.freshOffersRequired && (hotel.currentOfferRefs.length !== 0 || hotel.checksIssued >=
|
|
475
|
+
if (hotel.freshOffersRequired && (hotel.currentOfferRefs.length !== 0 || hotel.checksIssued >= MAX_OFFER_CHECKS_PER_HOTEL || hotel.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL)) return false
|
|
477
476
|
if (hotel.status === 'unvisited' || hotel.status === 'negative') {
|
|
478
477
|
if (hotel.status === 'unvisited' || hotel.lastEvidence !== 'unavailable' || hotel.currentOfferRefs.length || generation.valid) return false
|
|
479
478
|
}
|
|
@@ -483,7 +482,7 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
|
|
|
483
482
|
}
|
|
484
483
|
const attempts = state.attempts
|
|
485
484
|
if (new Set(attempts.map((attempt) => attempt.actionId)).size !== attempts.length) return false
|
|
486
|
-
const attemptsByHotel = new Map<string,
|
|
485
|
+
const attemptsByHotel = new Map<string, AvailabilityAttempt[]>()
|
|
487
486
|
for (const attempt of attempts) {
|
|
488
487
|
if (typeof attempt.actionId !== 'string' || !attempt.actionId || !state.hotels[attempt.hotelRef] || typeof attempt.offerRef !== 'string' || !attempt.offerRef || typeof attempt.offerVersionRef !== 'string' || !attempt.offerVersionRef || !Number.isSafeInteger(attempt.generation) || !Number.isSafeInteger(attempt.ordinal) || !Number.isSafeInteger(attempt.workspaceRevision) || attempt.workspaceRevision < 0) return false
|
|
489
488
|
const hotel = state.hotels[attempt.hotelRef]!
|
|
@@ -499,7 +498,7 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
|
|
|
499
498
|
if (new Set(state.queryReservations.map((reservation) => reservation.actionId)).size !== state.queryReservations.length) return false
|
|
500
499
|
const reservedHotels = new Set<string>()
|
|
501
500
|
for (const reservation of state.queryReservations) {
|
|
502
|
-
if (typeof reservation.actionId !== 'string' || !reservation.actionId || !Array.isArray(reservation.hotelRefs) || !reservation.hotelRefs.length || reservation.hotelRefs.length >
|
|
501
|
+
if (typeof reservation.actionId !== 'string' || !reservation.actionId || !Array.isArray(reservation.hotelRefs) || !reservation.hotelRefs.length || reservation.hotelRefs.length > MAX_HOTELS_PER_TASK || new Set(reservation.hotelRefs).size !== reservation.hotelRefs.length || reservation.hotelRefs.some((ref) => !state.hotels[ref]) || !Number.isSafeInteger(reservation.workspaceRevision) || reservation.workspaceRevision < 0 || reservation.hotelRefs.some((ref) => reservedHotels.has(ref))) return false
|
|
503
502
|
reservation.hotelRefs.forEach((ref) => reservedHotels.add(ref))
|
|
504
503
|
}
|
|
505
504
|
if (state.availabilityPhase === 'waiting_offers' && state.queryReservations.length === 0) return false
|
|
@@ -513,11 +512,11 @@ export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2):
|
|
|
513
512
|
if (state.availabilityPhase !== 'terminal' && state.terminal) return false
|
|
514
513
|
const recomputedTerminal = terminalFor(state)
|
|
515
514
|
if (state.terminal) {
|
|
516
|
-
if (!recomputedTerminal ||
|
|
515
|
+
if (!recomputedTerminal || digest(state.terminal) !== digest(recomputedTerminal)) return false
|
|
517
516
|
} else if (recomputedTerminal && !['waiting_offers', 'waiting_check'].includes(state.availabilityPhase)) return false
|
|
518
517
|
return true
|
|
519
518
|
} catch {
|
|
520
519
|
return false
|
|
521
520
|
}
|
|
522
521
|
}
|
|
523
|
-
export function
|
|
522
|
+
export function availabilityPolicyActionIsRelevant(action: BookingReadAction): boolean { return action.kind === 'offers.query' || action.kind === 'offer.check' }
|