@danceiny/gotry 0.0.1-rc.16 → 0.0.1-rc.18
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 +161 -116
- package/README.zh-CN.md +173 -144
- package/bin/gotry-booking-copilot.js +53 -0
- package/bin/gotry-bootstrap.js +113 -66
- package/bin/gotry-inner.js +436 -70
- package/bin/gotry-runtime-resolution.d.ts +27 -0
- package/bin/gotry-runtime-resolution.js +50 -0
- package/bin/gotry.js +1 -1
- package/cordis.gotry-patch.yml +5 -1
- package/dist/capabilities/agent-reach-deep.js +1 -1
- package/dist/capabilities/agent-reach.js +1 -1
- package/dist/capabilities/anything.js +1 -1
- package/dist/capabilities/artifacts.js +1 -1
- package/dist/capabilities/effect.js +1 -1
- package/dist/capabilities/fact-log.js +1 -1
- package/dist/capabilities/flyai.js +20 -6
- package/dist/capabilities/hbcli.js +1 -1
- package/dist/capabilities/incident-log.js +1 -1
- package/dist/capabilities/model-override.js +18 -0
- package/dist/capabilities/opensky.js +1 -1
- package/dist/capabilities/resilience.js +1 -1
- package/dist/capabilities/session/action-cache.js +1 -1
- package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
- package/dist/capabilities/session/adapters/meituan-local.js +1 -1
- package/dist/capabilities/session/benchmark.js +1 -1
- package/dist/capabilities/session/extension-bridge.js +19 -6
- package/dist/capabilities/session/extension-channel.js +3 -3
- package/dist/capabilities/session/extension-distribution.js +234 -0
- package/dist/capabilities/session/extract.js +1 -1
- package/dist/capabilities/session/golden-score.js +92 -0
- package/dist/capabilities/session/health-watch.js +1 -1
- package/dist/capabilities/session/read-guard.js +1 -1
- package/dist/capabilities/session/static-flight-golden.js +137 -0
- package/dist/capabilities/session/transport.js +1 -1
- package/dist/capabilities/session/wizard.js +21 -251
- package/dist/capabilities/session-consent.js +1 -1
- package/dist/capabilities/session-login.js +11 -5
- package/dist/capabilities/session-search.js +46 -4
- package/dist/capabilities/weather.js +168 -46
- package/dist/data/session-golden-20.json +25 -0
- package/dist/data/sf-golden-manifest.json +102 -0
- package/dist/data/sf-static-routes.json +91 -0
- package/dist/scripts/action-cache-tests.js +1 -1
- package/dist/scripts/agent-planning-budget-e2e.js +227 -0
- package/dist/scripts/agent-planning-budget-tests.js +173 -0
- package/dist/scripts/agent-reach-deep-tests.js +1 -1
- package/dist/scripts/agent-reach-tests.js +1 -1
- package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
- package/dist/scripts/anything-tests.js +1 -1
- package/dist/scripts/async-collect.js +1 -1
- package/dist/scripts/benchmark-environment-bridge-e2e.js +981 -0
- package/dist/scripts/benchmark-environment-bridge-tests.js +2544 -0
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +335 -0
- package/dist/scripts/booking-copilot-availability-policy-v2-tests.js +1355 -0
- package/dist/scripts/booking-copilot-bin-proof-tests.js +89 -0
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +113 -0
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +356 -0
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +557 -0
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +140 -0
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +202 -0
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +102 -0
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +374 -0
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +447 -0
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +205 -0
- package/dist/scripts/booking-copilot-server-proof-tests.js +317 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +283 -0
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +3935 -0
- package/dist/scripts/booking-saga-tests.js +1 -1
- package/dist/scripts/booking-surface-contract-proof-tests.js +393 -0
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +1174 -0
- package/dist/scripts/bootstrap-tests.js +45 -39
- package/dist/scripts/build-changelog.js +1 -1
- package/dist/scripts/changelog-tests.js +1 -1
- package/dist/scripts/companion-tests.js +1 -1
- package/dist/scripts/diff-test.js +1 -1
- package/dist/scripts/dsh-runtime-closure-tests.js +225 -0
- package/dist/scripts/dsh-runtime-closure.js +150 -0
- package/dist/scripts/effect-tests.js +1 -1
- package/dist/scripts/engine-run.js +1 -1
- package/dist/scripts/engine-tests.js +1 -1
- package/dist/scripts/evaluation-cadence-tests.js +347 -0
- package/dist/scripts/evaluation-contract-tests.js +574 -0
- package/dist/scripts/extension-distribution-cli.js +35 -0
- package/dist/scripts/extension-distribution-tests.js +341 -0
- package/dist/scripts/extension-tests.js +128 -23
- package/dist/scripts/fact-gate-tests.js +1 -1
- package/dist/scripts/flyai-tests.js +1 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -1
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/health-watch-cli.js +1 -1
- package/dist/scripts/i18n-tests.js +1 -1
- package/dist/scripts/incident-tests.js +1 -1
- package/dist/scripts/journey-tests.js +1 -1
- package/dist/scripts/ledger-tests.js +1 -1
- package/dist/scripts/ledger-workflow-crash.js +1 -1
- package/dist/scripts/memory-capture-tests.js +1 -1
- package/dist/scripts/memory-decay-tests.js +1 -1
- package/dist/scripts/memory-metrics.js +1 -1
- package/dist/scripts/memory-value-report.js +1 -1
- package/dist/scripts/model-override-e2e.js +176 -0
- package/dist/scripts/nightly-evidence-tests.js +1 -1
- package/dist/scripts/nightly-evidence.js +1 -1
- package/dist/scripts/nudge-digest.js +1 -1
- package/dist/scripts/onboarding-tests.js +21 -53
- package/dist/scripts/opensky-check.js +1 -1
- package/dist/scripts/opensky-tests.js +1 -1
- package/dist/scripts/pnpm-dsh-closure-proof.js +20 -0
- package/dist/scripts/price-drift-tests.js +1 -1
- package/dist/scripts/price-drift-watch.js +1 -1
- package/dist/scripts/probe-poi-tests.js +1 -1
- package/dist/scripts/product-metrics.js +1 -1
- package/dist/scripts/publish-preverify.js +40 -4
- package/dist/scripts/realtime-pricing-tests.js +1 -1
- package/dist/scripts/replay-async.js +1 -1
- package/dist/scripts/replay-real.js +1 -1
- package/dist/scripts/replay.js +1 -1
- package/dist/scripts/session-attach-diagnose.js +1 -1
- package/dist/scripts/session-attach-poc.js +1 -1
- package/dist/scripts/session-benchmark.js +1 -1
- package/dist/scripts/session-extract-tests.js +1 -1
- package/dist/scripts/session-login.js +1 -1
- package/dist/scripts/session-tests.js +70 -20
- package/dist/scripts/sf-live-benchmark.js +338 -0
- package/dist/scripts/sf-live-cli-tests.js +21 -0
- package/dist/scripts/sf-soft-score-tests.js +108 -0
- package/dist/scripts/sf-summary.js +93 -0
- package/dist/scripts/skeleton-check.js +1 -1
- package/dist/scripts/skeleton-integration-test.js +1 -1
- package/dist/scripts/skills-contract-tests.js +1 -1
- package/dist/scripts/smoke-session-gate-tests.js +29 -0
- package/dist/scripts/smoke.js +79 -33
- package/dist/scripts/state-cli-tests.js +1 -1
- package/dist/scripts/state-cli.js +1 -1
- package/dist/scripts/static-golden-tests.js +299 -0
- package/dist/scripts/time-eval-tests.js +1 -1
- package/dist/scripts/travel-timeline-tests.js +1 -1
- package/dist/scripts/unified-tests.js +1 -1
- package/dist/scripts/weather-tests.js +694 -44
- package/dist/scripts/z3-race-tests.js +1 -1
- package/dist/src/artifact-gate.js +1 -1
- package/dist/src/benchmark-agent-conformance.js +370 -0
- package/dist/src/benchmark-environment-bridge.js +384 -0
- package/dist/src/benchmark-headless-child-diagnostics.js +173 -0
- package/dist/src/benchmark-tool-isolation.js +124 -0
- package/dist/src/bookable-facts.js +1 -1
- package/dist/src/booking-saga.js +1 -1
- package/dist/src/booking-surface/availability-policy-v2.js +830 -0
- package/dist/src/booking-surface/canonical-schema.js +113 -0
- package/dist/src/booking-surface/contracts-v2.js +89 -0
- package/dist/src/booking-surface/contracts.js +46 -0
- package/dist/src/booking-surface/dsh-planner.js +453 -0
- package/dist/src/booking-surface/dsh-plugin.js +93 -0
- package/dist/src/booking-surface/error-codes.js +94 -0
- package/dist/src/booking-surface/index.js +15 -0
- package/dist/src/booking-surface/profile.js +68 -0
- package/dist/src/booking-surface/runtime-v2.js +1771 -0
- package/dist/src/booking-surface/runtime.js +351 -0
- package/dist/src/booking-surface/server-v2.js +334 -0
- package/dist/src/booking-surface/server.js +302 -0
- package/dist/src/booking-surface/startup.js +159 -0
- package/dist/src/booking-surface/validation-v2.js +319 -0
- package/dist/src/booking-surface/validation.js +809 -0
- package/dist/src/bridge.js +1 -1
- package/dist/src/companions.js +1 -1
- package/dist/src/contracts.js +1 -1
- package/dist/src/dsh-llm.js +1 -1
- package/dist/src/engine.js +1 -1
- package/dist/src/evaluation-cadence.js +234 -0
- package/dist/src/evaluation-contracts.js +906 -0
- package/dist/src/i18n.js +1 -1
- package/dist/src/index.js +50 -19
- package/dist/src/journey.js +1 -1
- package/dist/src/loop.js +1 -1
- package/dist/src/memory-capture.js +1 -1
- package/dist/src/memory-decay.js +1 -1
- package/dist/src/memory-utility.js +1 -1
- package/dist/src/mock-llm.js +1 -1
- package/dist/src/model.js +1 -1
- package/dist/src/realtime-pricing.js +1 -1
- package/dist/src/slot-spec.js +1 -1
- package/dist/src/state-ledger.js +2 -1
- package/dist/src/time-anchor.js +1 -1
- package/dist/src/tool-budget.js +136 -0
- package/dist/src/tool-packet.js +1 -1
- package/dist/src/travel-slots.js +1 -1
- package/dist/src/travel-timeline.js +1 -1
- package/dist/src/unified.js +1 -1
- package/dist/src/wish-pool.js +1 -1
- package/dist/src/z3-shared.js +1 -1
- package/extension/README.md +31 -7
- package/package.json +286 -11
- package/schemas/booking.surface.v1.schema.json +927 -0
- package/schemas/booking.surface.v2.schema.json +61 -0
- package/ts/capabilities/flyai.ts +16 -3
- package/ts/capabilities/session/extension-bridge.ts +37 -14
- package/ts/capabilities/session/extension-channel.ts +6 -3
- package/ts/capabilities/session/extension-distribution.ts +264 -0
- package/ts/capabilities/session/golden-score.ts +139 -0
- package/ts/capabilities/session/health-watch.ts +1 -1
- package/ts/capabilities/session/static-flight-golden.ts +209 -0
- package/ts/capabilities/session/wizard.ts +34 -176
- package/ts/capabilities/session-login.ts +15 -5
- package/ts/capabilities/session-search.ts +40 -3
- package/ts/capabilities/weather.ts +141 -52
- package/ts/package.json +3 -3
- package/ts/src/benchmark-agent-conformance.ts +448 -0
- package/ts/src/benchmark-environment-bridge.ts +348 -0
- package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
- package/ts/src/benchmark-tool-isolation.ts +166 -0
- package/ts/src/booking-surface/availability-policy-v2.ts +523 -0
- package/ts/src/booking-surface/canonical-schema.js +113 -0
- package/ts/src/booking-surface/contracts-v2.ts +118 -0
- package/ts/src/booking-surface/contracts.ts +380 -0
- package/ts/src/booking-surface/dsh-planner.ts +452 -0
- package/ts/src/booking-surface/dsh-plugin.js +93 -0
- package/ts/src/booking-surface/error-codes.ts +101 -0
- package/ts/src/booking-surface/index.ts +12 -0
- package/ts/src/booking-surface/profile.ts +42 -0
- package/ts/src/booking-surface/runtime-v2.ts +1466 -0
- package/ts/src/booking-surface/runtime.ts +483 -0
- package/ts/src/booking-surface/server-v2.ts +247 -0
- package/ts/src/booking-surface/server.ts +324 -0
- package/ts/src/booking-surface/startup.ts +196 -0
- package/ts/src/booking-surface/validation-v2.ts +205 -0
- package/ts/src/booking-surface/validation.ts +453 -0
- package/ts/src/index.ts +64 -11
- package/ts/src/state-ledger.ts +1 -0
- package/ts/src/tool-budget.ts +165 -0
- package/dist/scripts/wizard-bootstrap.js +0 -32
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import type { ActionReceiptV2, BookingReadActionV2, BookingWorkspaceSnapshotV2 } from './contracts-v2.ts'
|
|
2
|
+
import type { OfferCriteriaV1 } from './contracts.ts'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
/** Typed CheckAvail budget. This module never parses prose or retries a supplier. */
|
|
6
|
+
export const MAX_HOTELS_PER_TASK_V2 = 5
|
|
7
|
+
export const MAX_OFFERS_PER_HOTEL_GENERATION_V2 = 3
|
|
8
|
+
export const MAX_OFFER_CHECKS_PER_HOTEL_V2 = 2
|
|
9
|
+
export const MAX_OFFER_QUERIES_PER_HOTEL_V2 = 2
|
|
10
|
+
|
|
11
|
+
export type AvailabilityHotelStatusV2 = 'unvisited' | 'active' | 'negative' | 'confirmed' | 'inconclusive'
|
|
12
|
+
export type AvailabilityGenerationSourceV2 =
|
|
13
|
+
| { kind: 'query_receipt'; actionId: string; receiptDigest: string; workspaceRevision: number }
|
|
14
|
+
| { kind: 'workspace_snapshot'; workspaceDigest: string; workspaceRevision: number }
|
|
15
|
+
export interface AvailabilityGenerationV2 { generationId: string; source: AvailabilityGenerationSourceV2; offerSetDigest: string; orderedOfferRefs: string[]; evidence: 'complete' | 'partial'; valid: boolean }
|
|
16
|
+
export interface AvailabilityHotelStateV2 {
|
|
17
|
+
hotelRef: string; status: AvailabilityHotelStatusV2; checksIssued: number; offerQueriesIssued: number; generationNo: number
|
|
18
|
+
currentGeneration?: AvailabilityGenerationV2
|
|
19
|
+
/** Compatibility projection for callers needing current refs only. */
|
|
20
|
+
generation: number; currentOfferRefs: string[]; invalidatedOfferRefs: string[]; checkCount: number; freshOffersRequired: boolean
|
|
21
|
+
tombstonedOfferRefs: string[]; tombstonedOfferVersionRefs: string[]
|
|
22
|
+
lastEvidence: 'none' | 'confirmed' | 'unavailable' | 'inconclusive'
|
|
23
|
+
}
|
|
24
|
+
export interface AvailabilityAttemptV2 { actionId: string; hotelRef: string; offerRef: string; offerVersionRef: string; generation: number; ordinal: number; workspaceRevision: number }
|
|
25
|
+
export interface AvailabilityQueryReservationV2 { actionId: string; hotelRefs: string[]; workspaceRevision: number }
|
|
26
|
+
export interface AvailabilityExhaustionV2 {
|
|
27
|
+
code: 'availability_confirmed' | 'availability_exhausted_complete' | 'availability_exhausted_inconclusive'
|
|
28
|
+
hotelRefs: string[]; reason: 'no_current_offers' | 'check_limit_reached' | 'confirmed'; evidence: 'conclusive' | 'inconclusive'
|
|
29
|
+
}
|
|
30
|
+
export interface AvailabilityPolicyStateV2 {
|
|
31
|
+
initialized: boolean; recoveryStarted: boolean
|
|
32
|
+
availabilityPhase: 'need_offers' | 'waiting_offers' | 'need_check' | 'waiting_check' | 'terminal'
|
|
33
|
+
activeHotelOrdinal: number; hotelRefs: string[]; hotels: Record<string, AvailabilityHotelStateV2>; attempts: AvailabilityAttemptV2[]; queryReservations: AvailabilityQueryReservationV2[]; terminal?: AvailabilityExhaustionV2
|
|
34
|
+
recoveryId?: string; criteria?: OfferCriteriaV1; criteriaDigest?: string; candidateSetDigest?: string; lastQueryHotelRefs?: string[]; lastQueryCriteriaDigest?: string
|
|
35
|
+
/** Receipt-authoritative mapping used when a shortlist ref is no longer loaded. */
|
|
36
|
+
lastQueryOfferHotels?: Record<string, string>; lastQuerySourceActionId?: string; lastQuerySourceReceiptDigest?: string; lastQueryWorkspaceRevision?: number
|
|
37
|
+
}
|
|
38
|
+
export type AvailabilityPolicyDecisionV2 =
|
|
39
|
+
| { ok: true; hotelRef: string; offerRef: string; offerVersionRef: string; checkCount: number; generation: number }
|
|
40
|
+
| { ok: false; code: 'hotel_unknown' | 'offer_not_loaded' | 'offers_refresh_required' | 'hotel_check_limit_reached' | 'availability_exhausted' | 'generation_invalid' | 'hotel_not_active' }
|
|
41
|
+
|
|
42
|
+
type AvailabilityActionLikeV2 = { kind: BookingReadActionV2['kind']; actionId: string; input: Record<string, any>; expectedRevision?: number }
|
|
43
|
+
|
|
44
|
+
function unique(values: readonly string[]): string[] { return [...new Set(values)] }
|
|
45
|
+
export function assertWorkspaceLoadedOfferRefsUniqueV2(workspace: BookingWorkspaceSnapshotV2): void {
|
|
46
|
+
if (workspace.loadedOffers.length !== new Set(workspace.loadedOffers.map((offer) => offer.offerRef)).size) throw new Error('availability_duplicate_offer_ref')
|
|
47
|
+
if (workspace.loadedOffers.length !== new Set(workspace.loadedOffers.map((offer) => offer.offerVersionRef)).size) throw new Error('availability_duplicate_offer_version_ref')
|
|
48
|
+
}
|
|
49
|
+
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 digestV2(value: unknown): string { return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex') }
|
|
51
|
+
function workspaceDigestV2(workspace: BookingWorkspaceSnapshotV2): string { const { contextRef: _context, ...bound } = workspace as unknown as Record<string, unknown>; return digestV2(bound) }
|
|
52
|
+
function offerRefsForHotel(workspace: BookingWorkspaceSnapshotV2, hotelRef: string, selectedOfferRef?: string): string[] {
|
|
53
|
+
const refs = unique(workspace.loadedOffers.filter((offer) => offer.hotelRef === hotelRef).map((offer) => offer.offerRef))
|
|
54
|
+
if (selectedOfferRef) return unique([selectedOfferRef, ...refs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION_V2)
|
|
55
|
+
if (refs.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2) throw new Error('availability_offer_limit_exceeded')
|
|
56
|
+
return refs
|
|
57
|
+
}
|
|
58
|
+
function loadedOfferVersion(workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string) { return workspace.loadedOffers.find((offer) => offer.offerRef === offerRef && offer.offerVersionRef === offerVersionRef) }
|
|
59
|
+
function legalOfferRefsForHotel(workspace: BookingWorkspaceSnapshotV2, hotel: AvailabilityHotelStateV2, refs: readonly string[]): string[] {
|
|
60
|
+
const legal = refs.filter((ref) => {
|
|
61
|
+
if (hotel.tombstonedOfferRefs.includes(ref)) return false
|
|
62
|
+
const loaded = workspace.loadedOffers.find((offer) => offer.offerRef === ref && offer.hotelRef === hotel.hotelRef)
|
|
63
|
+
return Boolean(loaded && !hotel.tombstonedOfferVersionRefs.includes(loaded.offerVersionRef))
|
|
64
|
+
})
|
|
65
|
+
if (legal.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2) throw new Error('availability_offer_limit_exceeded')
|
|
66
|
+
return unique(legal)
|
|
67
|
+
}
|
|
68
|
+
function copyHotel(hotel: AvailabilityHotelStateV2): AvailabilityHotelStateV2 {
|
|
69
|
+
return { ...hotel, currentOfferRefs: [...hotel.currentOfferRefs], invalidatedOfferRefs: [...hotel.invalidatedOfferRefs], tombstonedOfferRefs: [...hotel.tombstonedOfferRefs], tombstonedOfferVersionRefs: [...hotel.tombstonedOfferVersionRefs], ...(hotel.currentGeneration ? { currentGeneration: { ...hotel.currentGeneration, orderedOfferRefs: [...hotel.currentGeneration.orderedOfferRefs] } } : {}) }
|
|
70
|
+
}
|
|
71
|
+
function copyState(state: AvailabilityPolicyStateV2): AvailabilityPolicyStateV2 {
|
|
72
|
+
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
|
+
}
|
|
74
|
+
|
|
75
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) }
|
|
76
|
+
function isCriterion(value: unknown, valueCheck: (candidate: unknown) => boolean): boolean {
|
|
77
|
+
return isPlainRecord(value) && Object.keys(value).length === 2 && (value.strength === 'must' || value.strength === 'prefer') && valueCheck(value.value)
|
|
78
|
+
}
|
|
79
|
+
function isMoney(value: unknown): boolean {
|
|
80
|
+
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
|
+
}
|
|
82
|
+
function isOfferCriteriaV2(value: unknown): value is OfferCriteriaV1 {
|
|
83
|
+
if (!isPlainRecord(value)) return false
|
|
84
|
+
const allowed = ['roomType', 'bedType', 'meals', 'freeCancellation', 'freeCancellationUntil', 'totalPriceMax', 'roomsAvailableMin', 'payAtProperty', 'mobileRate', 'targetCount', 'sort']
|
|
85
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) return false
|
|
86
|
+
const strings = (candidate: unknown): boolean => Array.isArray(candidate) && candidate.every((item) => typeof item === 'string' && Boolean(item))
|
|
87
|
+
if (value.roomType !== undefined && !isCriterion(value.roomType, strings)) return false
|
|
88
|
+
if (value.bedType !== undefined && !isCriterion(value.bedType, strings)) return false
|
|
89
|
+
if (value.meals !== undefined && !isCriterion(value.meals, strings)) return false
|
|
90
|
+
if (value.freeCancellation !== undefined && !isCriterion(value.freeCancellation, (candidate) => typeof candidate === 'boolean')) return false
|
|
91
|
+
if (value.freeCancellationUntil !== undefined && !isCriterion(value.freeCancellationUntil, (candidate) => typeof candidate === 'string' && Boolean(candidate))) return false
|
|
92
|
+
if (value.totalPriceMax !== undefined && !isCriterion(value.totalPriceMax, isMoney)) return false
|
|
93
|
+
if (value.roomsAvailableMin !== undefined && !isCriterion(value.roomsAvailableMin, (candidate) => Number.isSafeInteger(candidate) && (candidate as number) >= 0)) return false
|
|
94
|
+
if (value.payAtProperty !== undefined && !isCriterion(value.payAtProperty, (candidate) => typeof candidate === 'boolean')) return false
|
|
95
|
+
if (value.mobileRate !== undefined && !isCriterion(value.mobileRate, (candidate) => typeof candidate === 'boolean')) return false
|
|
96
|
+
if (value.targetCount !== undefined && (!Number.isSafeInteger(value.targetCount) || (value.targetCount as number) < 0)) return false
|
|
97
|
+
if (value.sort !== undefined && !['best_match', 'total_price_asc', 'cancellation_latest'].includes(value.sort as string)) return false
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
function bindCriteria(next: AvailabilityPolicyStateV2, criteriaDigest: string, criteria?: OfferCriteriaV1, required = false): void {
|
|
101
|
+
if (criteria !== undefined) {
|
|
102
|
+
if (!isOfferCriteriaV2(criteria) || digestV2(criteria) !== criteriaDigest) throw new Error('availability_criteria_invalid')
|
|
103
|
+
if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest) throw new Error('availability_criteria_changed')
|
|
104
|
+
if (next.criteria && digestV2(next.criteria) !== criteriaDigest) throw new Error('availability_criteria_changed')
|
|
105
|
+
next.criteria = structuredClone(criteria); next.criteriaDigest = criteriaDigest
|
|
106
|
+
} else if (required && !next.criteria) throw new Error('availability_criteria_required')
|
|
107
|
+
else if (next.criteria && next.criteriaDigest !== digestV2(next.criteria)) throw new Error('availability_criteria_mismatch')
|
|
108
|
+
else if (!next.criteria && criteriaDigest) {
|
|
109
|
+
if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest) throw new Error('availability_criteria_changed')
|
|
110
|
+
next.criteriaDigest = criteriaDigest
|
|
111
|
+
}
|
|
112
|
+
if (next.criteriaDigest !== undefined && next.criteriaDigest !== criteriaDigest && criteriaDigest) throw new Error('availability_criteria_changed')
|
|
113
|
+
}
|
|
114
|
+
function terminalFor(state: AvailabilityPolicyStateV2): AvailabilityExhaustionV2 | undefined {
|
|
115
|
+
if (!state.recoveryStarted || !state.hotelRefs.length) return undefined
|
|
116
|
+
const hotels = state.hotelRefs.map((ref) => state.hotels[ref]!)
|
|
117
|
+
if (hotels.some((hotel) => hotel.status === 'confirmed')) return { code: 'availability_confirmed', hotelRefs: [...state.hotelRefs], reason: 'confirmed', evidence: 'conclusive' }
|
|
118
|
+
// A negative result is conclusive only when it has no legal refresh left.
|
|
119
|
+
// The first manual check starts with a workspace generation (zero recovery
|
|
120
|
+
// queries), so it must leave a fresh-query transition available.
|
|
121
|
+
if (hotels.some((hotel) =>
|
|
122
|
+
hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL_V2
|
|
123
|
+
&& (
|
|
124
|
+
hotel.status === 'active'
|
|
125
|
+
|| hotel.status === 'unvisited'
|
|
126
|
+
|| (hotel.freshOffersRequired && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2)
|
|
127
|
+
)
|
|
128
|
+
)) return undefined
|
|
129
|
+
const conclusive = hotels.every((hotel) => hotel.status === 'negative')
|
|
130
|
+
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
|
+
}
|
|
132
|
+
function withTerminal(state: AvailabilityPolicyStateV2): AvailabilityPolicyStateV2 { const terminal = terminalFor(state); return terminal ? { ...state, terminal, availabilityPhase: 'terminal' } : state }
|
|
133
|
+
function newHotel(hotelRef: string): AvailabilityHotelStateV2 { return { hotelRef, status: 'unvisited', checksIssued: 0, offerQueriesIssued: 0, generationNo: 0, generation: 0, currentOfferRefs: [], invalidatedOfferRefs: [], checkCount: 0, freshOffersRequired: false, tombstonedOfferRefs: [], tombstonedOfferVersionRefs: [], lastEvidence: 'none' } }
|
|
134
|
+
function phaseForActiveHotel(state: AvailabilityPolicyStateV2): 'need_offers' | 'need_check' {
|
|
135
|
+
const active = state.hotels[state.hotelRefs[state.activeHotelOrdinal] ?? '']
|
|
136
|
+
return active?.status === 'active' && active.currentGeneration?.valid && active.currentOfferRefs.length > 0 && !active.freshOffersRequired
|
|
137
|
+
? 'need_check'
|
|
138
|
+
: 'need_offers'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Initial workspace is only a hint; the bounded recovery starts at typed offers.query. */
|
|
142
|
+
export function createAvailabilityPolicyV2(workspace: BookingWorkspaceSnapshotV2): AvailabilityPolicyStateV2 { assertWorkspaceLoadedOfferRefsUniqueV2(workspace); return { initialized: false, recoveryStarted: false, availabilityPhase: 'need_offers', activeHotelOrdinal: 0, hotelRefs: [], hotels: {}, attempts: [], queryReservations: [] } }
|
|
143
|
+
/** Ordinary search results never get silently truncated into recovery candidates. */
|
|
144
|
+
export function recordVisibleHotelsV2(state: AvailabilityPolicyStateV2, _workspace: BookingWorkspaceSnapshotV2): AvailabilityPolicyStateV2 { return copyState(state) }
|
|
145
|
+
/** Observe a normal offers query without opening an availability recovery. */
|
|
146
|
+
export function recordObservedOffersQueryV2(state: AvailabilityPolicyStateV2, hotelRefs: readonly string[], criteriaDigest: string, workspace?: BookingWorkspaceSnapshotV2, receipt?: ActionReceiptV2, sourceActionId?: string, receiptDigest?: string, criteria?: OfferCriteriaV1): AvailabilityPolicyStateV2 {
|
|
147
|
+
if (workspace) assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
|
|
148
|
+
if (state.recoveryStarted) return copyState(state)
|
|
149
|
+
const next = copyState(state)
|
|
150
|
+
if (!workspace || !receipt) return next
|
|
151
|
+
delete next.lastQueryHotelRefs; delete next.lastQueryCriteriaDigest; delete next.lastQueryOfferHotels; delete next.lastQuerySourceActionId; delete next.lastQuerySourceReceiptDigest; delete next.lastQueryWorkspaceRevision
|
|
152
|
+
const completeReceipt = receipt?.status === 'applied'
|
|
153
|
+
&& receipt.resultContract.outcome === 'complete'
|
|
154
|
+
&& receipt.resultContract.hardCriteriaMet
|
|
155
|
+
&& !receipt.resultContract.gapCodes.length
|
|
156
|
+
&& !receipt.resultContract.blockers.length
|
|
157
|
+
&& receipt.observation.kind === 'offers.state'
|
|
158
|
+
&& !receipt.observation.gapCodes?.length
|
|
159
|
+
if (completeReceipt && receipt.observation.kind === 'offers.state') {
|
|
160
|
+
if (!criteria) throw new Error('availability_criteria_required')
|
|
161
|
+
// Ordinary queries establish the latest recovery seed. Before recovery
|
|
162
|
+
// starts, a newer complete query is allowed to replace an older seed.
|
|
163
|
+
delete next.criteria
|
|
164
|
+
delete next.criteriaDigest
|
|
165
|
+
bindCriteria(next, criteriaDigest, criteria)
|
|
166
|
+
if (!sourceActionId || sourceActionId !== receipt.actionId || !receiptDigest || receiptDigest !== digestV2(receipt)) throw new Error('availability_query_provenance_mismatch')
|
|
167
|
+
next.lastQueryHotelRefs = [...new Set(hotelRefs)]
|
|
168
|
+
next.lastQueryCriteriaDigest = criteriaDigest || undefined
|
|
169
|
+
next.lastQueryOfferHotels = validateOffersReceiptWorkspaceV2(receipt, workspace, hotelRefs)
|
|
170
|
+
next.lastQuerySourceActionId = sourceActionId
|
|
171
|
+
next.lastQuerySourceReceiptDigest = receiptDigest
|
|
172
|
+
next.lastQueryWorkspaceRevision = workspace.revision
|
|
173
|
+
} else {
|
|
174
|
+
// The latest ordinary query is the only candidate provenance. A failed
|
|
175
|
+
// or partial query must not leave an older criteria object usable for a
|
|
176
|
+
// later manual recovery.
|
|
177
|
+
delete next.criteria
|
|
178
|
+
delete next.criteriaDigest
|
|
179
|
+
}
|
|
180
|
+
return next
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Reconcile an offers receipt against the post-action workspace before its
|
|
185
|
+
* provenance can influence recovery. Complete receipts must describe exactly
|
|
186
|
+
* the authoritative loaded offers; partial/gap receipts may only describe an
|
|
187
|
+
* evidenced subset. An unreported offer is never silently admitted.
|
|
188
|
+
*/
|
|
189
|
+
export function validateOffersReceiptWorkspaceV2(receipt: ActionReceiptV2, workspace: BookingWorkspaceSnapshotV2, hotelRefs: readonly string[]): Record<string, string> {
|
|
190
|
+
assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
|
|
191
|
+
if (receipt.observation.kind !== 'offers.state') throw new Error('availability_offers_observation_required')
|
|
192
|
+
const requested = unique(hotelRefs)
|
|
193
|
+
if (!sameSet(receipt.observation.hotelRefs, requested)) throw new Error('availability_offers_hotel_mismatch')
|
|
194
|
+
const authoritative = workspace.loadedOffers.filter((offer) => requested.includes(offer.hotelRef))
|
|
195
|
+
const authoritativeRefs = unique(authoritative.map((offer) => offer.offerRef))
|
|
196
|
+
const observedRefs = unique(receipt.observation.offerRefs)
|
|
197
|
+
if (observedRefs.some((offerRef) => !authoritativeRefs.includes(offerRef))) throw new Error('availability_offers_unreported')
|
|
198
|
+
const authoritativeHotels = new Set(authoritative.map((offer) => offer.hotelRef)).size
|
|
199
|
+
const observedOfferHotels = Object.fromEntries(observedRefs.map((offerRef) => [offerRef, authoritative.find((offer) => offer.offerRef === offerRef)!.hotelRef]))
|
|
200
|
+
const observedHotels = new Set(Object.values(observedOfferHotels)).size
|
|
201
|
+
const clean = !receipt.resultContract.gapCodes.length && !receipt.resultContract.blockers.length && !receipt.observation.gapCodes?.length
|
|
202
|
+
if ((receipt.status === 'no_match' || receipt.status === 'unavailable') && (receipt.resultContract.outcome !== 'empty' || observedRefs.length !== 0 || receipt.observation.loadedHotelCount !== 0)) throw new Error('availability_offers_receipt_incoherent')
|
|
203
|
+
const complete = receipt.status === 'applied' && receipt.resultContract.outcome === 'complete' && receipt.resultContract.hardCriteriaMet && clean
|
|
204
|
+
const exactEmpty = clean && receipt.resultContract.outcome === 'empty' && !['changed', 'partial', 'stale', 'failed', 'unsupported'].includes(receipt.status)
|
|
205
|
+
if ((complete || exactEmpty) && (!sameSet(observedRefs, authoritativeRefs) || receipt.observation.loadedHotelCount !== authoritativeHotels)) throw new Error('availability_offers_workspace_mismatch')
|
|
206
|
+
if (receipt.observation.loadedHotelCount !== observedHotels) throw new Error('availability_offers_count_mismatch')
|
|
207
|
+
return observedOfferHotels
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
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
|
+
|
|
212
|
+
/** A typed offers.query receipt starts/advances a bounded candidate recovery. */
|
|
213
|
+
export function recordOffersQueryIssuedV2(state: AvailabilityPolicyStateV2, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshotV2, actionId: string, criteriaDigest = '', criteria?: OfferCriteriaV1): AvailabilityPolicyStateV2 {
|
|
214
|
+
assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
|
|
215
|
+
const next = copyState(state); const requested = unique(hotelRefs)
|
|
216
|
+
if (!requested.length || requested.length > MAX_HOTELS_PER_TASK_V2) throw new Error('availability_hotel_limit_exceeded')
|
|
217
|
+
if (!state.recoveryStarted) throw new Error('availability_recovery_not_started')
|
|
218
|
+
if (state.terminal) {
|
|
219
|
+
if (requested.some((ref) => state.hotels[ref]?.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL_V2)) throw new Error('availability_offer_query_limit_reached')
|
|
220
|
+
throw new Error('availability_terminal')
|
|
221
|
+
}
|
|
222
|
+
if (requested.some((ref) => !next.hotels[ref])) throw new Error('availability_hotel_unknown')
|
|
223
|
+
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')
|
|
224
|
+
bindCriteria(next, criteriaDigest, criteria)
|
|
225
|
+
const existingReservation = next.queryReservations.find((reservation) => reservation.actionId === actionId)
|
|
226
|
+
if (existingReservation) {
|
|
227
|
+
if (!sameSet(existingReservation.hotelRefs, requested) || existingReservation.workspaceRevision !== workspace.revision) throw new Error('availability_query_action_conflict')
|
|
228
|
+
return next
|
|
229
|
+
}
|
|
230
|
+
for (const hotelRef of requested) { const hotel = next.hotels[hotelRef]!; if (hotel.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL_V2) throw new Error('availability_offer_query_limit_reached'); hotel.offerQueriesIssued += 1 }
|
|
231
|
+
next.queryReservations.push({ actionId, hotelRefs: [...requested], workspaceRevision: workspace.revision }); next.availabilityPhase = 'waiting_offers'; next.initialized = true; return next
|
|
232
|
+
}
|
|
233
|
+
export function recordOffersGenerationV2(state: AvailabilityPolicyStateV2, hotelRefs: readonly string[], workspace: BookingWorkspaceSnapshotV2, actionId: string, receiptDigest: string, receipt: ActionReceiptV2, criteriaDigest = '', criteria?: OfferCriteriaV1): AvailabilityPolicyStateV2 {
|
|
234
|
+
assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
|
|
235
|
+
if (!state.recoveryStarted) return copyState(state)
|
|
236
|
+
if (state.terminal) throw new Error('availability_terminal')
|
|
237
|
+
const next = copyState(state); const requested = unique(hotelRefs)
|
|
238
|
+
if (!requested.length || requested.length > MAX_HOTELS_PER_TASK_V2) throw new Error('availability_hotel_limit_exceeded')
|
|
239
|
+
const reservation = next.queryReservations.find((query) => query.actionId === actionId)
|
|
240
|
+
if (!reservation || !sameSet(reservation.hotelRefs, requested)) throw new Error('availability_query_not_reserved')
|
|
241
|
+
if (receipt.actionId !== actionId) throw new Error('availability_query_receipt_mismatch')
|
|
242
|
+
if (receiptDigest !== digestV2(receipt)) throw new Error('availability_receipt_digest_mismatch')
|
|
243
|
+
if (requested.some((ref) => !next.hotels[ref])) throw new Error('availability_hotel_unknown')
|
|
244
|
+
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
|
+
bindCriteria(next, criteriaDigest, criteria)
|
|
246
|
+
if (receipt.observation.kind === 'offers.state') validateOffersReceiptWorkspaceV2(receipt, workspace, requested)
|
|
247
|
+
for (const hotelRef of requested) {
|
|
248
|
+
const hotel = next.hotels[hotelRef]!
|
|
249
|
+
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))
|
|
250
|
+
const candidateRefs = partial
|
|
251
|
+
? receipt.observation.kind === 'offers.state'
|
|
252
|
+
? receipt.observation.offerRefs.filter((ref) => workspace.loadedOffers.some((offer) => offer.offerRef === ref && offer.hotelRef === hotelRef))
|
|
253
|
+
: []
|
|
254
|
+
: offerRefsForHotel(workspace, hotelRef)
|
|
255
|
+
const refs = legalOfferRefsForHotel(workspace, hotel, candidateRefs)
|
|
256
|
+
const unusable = ['stale', 'failed', 'unsupported'].includes(receipt.status)
|
|
257
|
+
if (unusable) { hotel.currentOfferRefs = []; hotel.invalidatedOfferRefs = [...(hotel.currentGeneration?.orderedOfferRefs ?? [])]; hotel.freshOffersRequired = hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL_V2 && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2; hotel.status = 'inconclusive'; hotel.lastEvidence = 'inconclusive'; continue }
|
|
258
|
+
const filteredAllCandidates = candidateRefs.length > 0 && refs.length === 0
|
|
259
|
+
const noLegalRefsNeedsRefresh = filteredAllCandidates && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2
|
|
260
|
+
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_V2 && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2; hotel.status = partial || noLegalRefsNeedsRefresh ? 'inconclusive' : refs.length ? 'active' : 'negative'; hotel.lastEvidence = partial || noLegalRefsNeedsRefresh ? 'inconclusive' : refs.length ? 'none' : 'unavailable'
|
|
261
|
+
hotel.currentGeneration = { generationId: `${hotelRef}:generation:${hotel.generationNo}`, source: { kind: 'query_receipt', actionId, receiptDigest, workspaceRevision: workspace.revision }, offerSetDigest: digestV2(refs), orderedOfferRefs: refs, evidence: partial ? 'partial' : 'complete', valid: refs.length > 0 && !partial }
|
|
262
|
+
}
|
|
263
|
+
const firstRequested = next.hotelRefs.indexOf(requested[0]!)
|
|
264
|
+
if (firstRequested >= 0) next.activeHotelOrdinal = firstRequested
|
|
265
|
+
const queried = next.hotels[requested[0]!]!
|
|
266
|
+
if (!queried.currentOfferRefs.length && !queried.freshOffersRequired) {
|
|
267
|
+
const fallback = next.hotelRefs.findIndex((ref) => ['active', 'unvisited'].includes(next.hotels[ref]!.status))
|
|
268
|
+
if (fallback >= 0) next.activeHotelOrdinal = fallback
|
|
269
|
+
}
|
|
270
|
+
next.queryReservations = next.queryReservations.filter((query) => query.actionId !== actionId)
|
|
271
|
+
next.initialized = true; next.availabilityPhase = phaseForActiveHotel(next); delete next.terminal; return withTerminal(next)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function canIssueOfferCheckV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string): AvailabilityPolicyDecisionV2 {
|
|
275
|
+
assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
|
|
276
|
+
// Availability recovery is opt-in at the typed offers.query seam. Existing
|
|
277
|
+
// v2 read flows retain their pre-recovery offer.check behavior.
|
|
278
|
+
if (!state.recoveryStarted) {
|
|
279
|
+
const loaded = loadedOfferVersion(workspace, offerRef, offerVersionRef)
|
|
280
|
+
return loaded
|
|
281
|
+
? { ok: true, hotelRef: loaded.hotelRef, offerRef, offerVersionRef, checkCount: 0, generation: 0 }
|
|
282
|
+
: { ok: false, code: 'offer_not_loaded' }
|
|
283
|
+
}
|
|
284
|
+
if (state.terminal) return { ok: false, code: 'availability_exhausted' }
|
|
285
|
+
const loaded = loadedOfferVersion(workspace, offerRef, offerVersionRef); if (!loaded) return { ok: false, code: 'offer_not_loaded' }
|
|
286
|
+
const hotel = state.hotels[loaded.hotelRef]; if (!hotel) return { ok: false, code: 'hotel_unknown' }
|
|
287
|
+
if (state.hotelRefs[state.activeHotelOrdinal] !== loaded.hotelRef) return { ok: false, code: 'hotel_not_active' }
|
|
288
|
+
if (hotel.freshOffersRequired) return { ok: false, code: 'offers_refresh_required' }
|
|
289
|
+
if (hotel.tombstonedOfferRefs.includes(offerRef) || hotel.tombstonedOfferVersionRefs.includes(offerVersionRef)) return { ok: false, code: 'generation_invalid' }
|
|
290
|
+
if (!hotel.currentGeneration?.valid || !hotel.currentGeneration.orderedOfferRefs.includes(offerRef) || hotel.invalidatedOfferRefs.includes(offerRef)) return { ok: false, code: 'generation_invalid' }
|
|
291
|
+
if (hotel.checksIssued >= MAX_OFFER_CHECKS_PER_HOTEL_V2) return { ok: false, code: 'hotel_check_limit_reached' }
|
|
292
|
+
return { ok: true, hotelRef: loaded.hotelRef, offerRef, offerVersionRef, checkCount: hotel.checksIssued + 1, generation: hotel.generationNo }
|
|
293
|
+
}
|
|
294
|
+
/** Start the bounded recovery epoch at the first typed offer.check. */
|
|
295
|
+
function startRecoveryAtOfferV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, offerRef: string, actionId: string): AvailabilityPolicyStateV2 {
|
|
296
|
+
assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
|
|
297
|
+
const loaded = workspace.loadedOffers.find((offer) => offer.offerRef === offerRef)
|
|
298
|
+
if (!loaded) return copyState(state)
|
|
299
|
+
const shortlistHotels = workspace.shortlistedOfferRefs.map((ref) => workspace.loadedOffers.find((offer) => offer.offerRef === ref)?.hotelRef ?? state.lastQueryOfferHotels?.[ref]).map((ref, index) => {
|
|
300
|
+
if (!ref) throw new Error(`availability_shortlist_unmapped:${workspace.shortlistedOfferRefs[index]}`)
|
|
301
|
+
return ref
|
|
302
|
+
})
|
|
303
|
+
const fallbackHotels = shortlistHotels.length ? shortlistHotels : []
|
|
304
|
+
const candidates = unique([loaded.hotelRef, ...fallbackHotels]).slice(0, MAX_HOTELS_PER_TASK_V2)
|
|
305
|
+
const next = copyState(state); next.recoveryStarted = true; next.recoveryId = `recovery:${actionId}`; next.hotelRefs = candidates; next.candidateSetDigest = digestV2(candidates); next.hotels = Object.fromEntries(candidates.map((ref) => [ref, newHotel(ref)]))
|
|
306
|
+
const nextHotel = next.hotels[loaded.hotelRef]!
|
|
307
|
+
const mappedRefs = state.lastQueryOfferHotels ? Object.entries(state.lastQueryOfferHotels).filter(([, hotel]) => hotel === loaded.hotelRef).map(([ref]) => ref) : []
|
|
308
|
+
const currentRefs = unique(workspace.loadedOffers.filter((offer) => offer.hotelRef === loaded.hotelRef).map((offer) => offer.offerRef))
|
|
309
|
+
const mapped = state.lastQueryOfferHotels?.[offerRef] === loaded.hotelRef
|
|
310
|
+
&& state.lastQueryWorkspaceRevision === workspace.revision
|
|
311
|
+
&& Boolean(state.lastQuerySourceActionId && state.lastQuerySourceReceiptDigest)
|
|
312
|
+
&& sameSet(mappedRefs, currentRefs)
|
|
313
|
+
if (mapped && state.criteria) { next.criteria = structuredClone(state.criteria); next.criteriaDigest = digestV2(next.criteria) }
|
|
314
|
+
const refs = mapped && state.lastQueryOfferHotels
|
|
315
|
+
? unique([offerRef, ...mappedRefs]).slice(0, MAX_OFFERS_PER_HOTEL_GENERATION_V2)
|
|
316
|
+
: offerRefsForHotel(workspace, loaded.hotelRef, offerRef)
|
|
317
|
+
const source = mapped
|
|
318
|
+
? { kind: 'query_receipt' as const, actionId: state.lastQuerySourceActionId!, receiptDigest: state.lastQuerySourceReceiptDigest!, workspaceRevision: state.lastQueryWorkspaceRevision! }
|
|
319
|
+
: { kind: 'workspace_snapshot' as const, workspaceDigest: workspaceDigestV2(workspace), workspaceRevision: workspace.revision }
|
|
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: digestV2(refs), orderedOfferRefs: refs, evidence: 'complete', valid: refs.length > 0 }; next.initialized = true; next.availabilityPhase = 'need_check'; return next
|
|
321
|
+
}
|
|
322
|
+
export function recordOfferCheckIssuedV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, offerRef: string, offerVersionRef: string, actionId = `offer-check-${offerRef}-${state.attempts.length + 1}`): AvailabilityPolicyStateV2 {
|
|
323
|
+
assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
|
|
324
|
+
if (state.terminal) throw new Error('availability_terminal')
|
|
325
|
+
if (!state.recoveryStarted) {
|
|
326
|
+
if (!loadedOfferVersion(workspace, offerRef, offerVersionRef)) throw new Error('availability_offer_not_loaded')
|
|
327
|
+
const started = startRecoveryAtOfferV2(state, workspace, offerRef, actionId)
|
|
328
|
+
return recordOfferCheckIssuedV2(started, workspace, offerRef, offerVersionRef, actionId)
|
|
329
|
+
}
|
|
330
|
+
const decision = canIssueOfferCheckV2(state, workspace, offerRef, offerVersionRef); if (!decision.ok) throw new Error(`availability_${decision.code}`)
|
|
331
|
+
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
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Fold the availability portion of an issued action. Keeping this reducer
|
|
336
|
+
* next to the receipt reducers makes ledger replay use precisely the same
|
|
337
|
+
* transition as the write path.
|
|
338
|
+
*/
|
|
339
|
+
export function reduceAvailabilityActionV2(
|
|
340
|
+
state: AvailabilityPolicyStateV2,
|
|
341
|
+
workspace: BookingWorkspaceSnapshotV2,
|
|
342
|
+
action: AvailabilityActionLikeV2,
|
|
343
|
+
): AvailabilityPolicyStateV2 {
|
|
344
|
+
if (action.kind === 'offers.query') {
|
|
345
|
+
if (!isOfferCriteriaV2(action.input.criteria) && (!state.criteria || !isOfferCriteriaV2(state.criteria))) throw new Error('availability_criteria_required')
|
|
346
|
+
if (!isOfferCriteriaV2(action.input.criteria) && state.recoveryStarted) throw new Error('availability_criteria_required')
|
|
347
|
+
if (!isOfferCriteriaV2(action.input.criteria)) throw new Error('availability_criteria_required')
|
|
348
|
+
const criteriaDigest = digestV2(action.input.criteria)
|
|
349
|
+
let next = recordObservedOffersQueryV2(state, action.input.hotelRefs, criteriaDigest)
|
|
350
|
+
if (state.recoveryStarted) next = recordOffersQueryIssuedV2(next, action.input.hotelRefs, workspace, action.actionId, criteriaDigest, action.input.criteria)
|
|
351
|
+
return next
|
|
352
|
+
}
|
|
353
|
+
if (action.kind === 'offer.check') return recordOfferCheckIssuedV2(state, workspace, action.input.offerRef, action.input.offerVersionRef, action.actionId)
|
|
354
|
+
return copyState(state)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Fold a receipt against the exact durable attempt. Non-confirming results invalidate the whole generation. */
|
|
358
|
+
export function recordOfferCheckReceiptV2(state: AvailabilityPolicyStateV2, workspace: BookingWorkspaceSnapshotV2, receipt: ActionReceiptV2, actionId: string, offerRef: string, offerVersionRef: string, expectedRevision: number): AvailabilityPolicyStateV2 {
|
|
359
|
+
assertWorkspaceLoadedOfferRefsUniqueV2(workspace)
|
|
360
|
+
if (!state.recoveryStarted) return copyState(state)
|
|
361
|
+
const attempt = state.attempts.find((candidate) => candidate.actionId === actionId); if (!attempt) throw new Error('availability_attempt_unknown')
|
|
362
|
+
if (receipt.actionId !== actionId) throw new Error('availability_attempt_mismatch')
|
|
363
|
+
if (receipt.revision !== workspace.revision || receipt.revision < expectedRevision) throw new Error('availability_stale_receipt')
|
|
364
|
+
const targetOfferRef = receipt.observation.kind === 'offer.availability' ? receipt.observation.offerRef : offerRef
|
|
365
|
+
const targetVersionRef = receipt.observation.kind === 'offer.availability' ? receipt.observation.checkedOfferVersionRef : offerVersionRef
|
|
366
|
+
if (attempt.offerRef !== targetOfferRef || attempt.offerVersionRef !== targetVersionRef || offerRef !== targetOfferRef || offerVersionRef !== targetVersionRef || attempt.workspaceRevision !== expectedRevision) throw new Error('availability_attempt_mismatch')
|
|
367
|
+
const next = copyState(state); const hotel = next.hotels[attempt.hotelRef]; if (!hotel || !hotel.currentGeneration || attempt.generation !== hotel.generationNo) throw new Error('availability_generation_mismatch')
|
|
368
|
+
const observation = receipt.observation.kind === 'offer.availability' ? receipt.observation : undefined
|
|
369
|
+
if (['applied', 'changed', 'unavailable', 'no_match'].includes(receipt.status) && !observation) throw new Error('availability_observation_required')
|
|
370
|
+
if (observation?.available && (receipt.resultContract.outcome === 'empty' || ['unavailable', 'no_match', 'failed', 'stale', 'unsupported'].includes(receipt.status))) throw new Error('availability_receipt_incoherent')
|
|
371
|
+
if (observation && !observation.available && (observation.verifiedOfferRef || observation.currentOfferVersionRef)) throw new Error('availability_receipt_incoherent')
|
|
372
|
+
if (['unavailable', 'no_match'].includes(receipt.status) && receipt.resultContract.outcome !== 'empty') throw new Error('availability_receipt_incoherent')
|
|
373
|
+
const confirmed = receipt.status === 'applied' && Boolean(observation?.available && observation.currentOfferVersionRef === offerVersionRef && observation.verifiedOfferRef && workspace.verifiedOffer?.offerRef === offerRef && workspace.verifiedOffer.offerVersionRef === offerVersionRef && workspace.verifiedOffer.verifiedOfferRef === observation.verifiedOfferRef && loadedOfferVersion(workspace, offerRef, offerVersionRef)) && !observation?.changedFactRefs.length && !observation?.gapCodes?.length && receipt.resultContract.outcome === 'complete' && receipt.resultContract.hardCriteriaMet && !receipt.resultContract.blockers.length && !receipt.resultContract.gapCodes.length
|
|
374
|
+
if (confirmed) { hotel.status = 'confirmed'; hotel.lastEvidence = 'confirmed'; hotel.currentGeneration.valid = false; return withTerminal(next) }
|
|
375
|
+
if (receipt.status === 'changed' && observation?.available && observation.currentOfferVersionRef && observation.currentOfferVersionRef !== offerVersionRef && observation.changedFactRefs.length && !observation.verifiedOfferRef && loadedOfferVersion(workspace, offerRef, observation.currentOfferVersionRef)) {
|
|
376
|
+
hotel.tombstonedOfferVersionRefs = unique([...hotel.tombstonedOfferVersionRefs, offerVersionRef])
|
|
377
|
+
hotel.currentGeneration.valid = true; hotel.invalidatedOfferRefs = []; hotel.currentOfferRefs = [offerRef]; hotel.freshOffersRequired = false; hotel.status = 'active'; hotel.lastEvidence = 'none'; next.availabilityPhase = 'need_check'; return withTerminal(next)
|
|
378
|
+
}
|
|
379
|
+
const completeNegative = (receipt.status === 'unavailable' || receipt.status === 'no_match') && receipt.resultContract.outcome === 'empty' && observation?.available === false && receipt.resultContract.gapCodes.length === 0 && !receipt.resultContract.blockers.length && !observation.gapCodes?.length
|
|
380
|
+
if (completeNegative) {
|
|
381
|
+
hotel.tombstonedOfferVersionRefs = unique([...hotel.tombstonedOfferVersionRefs, offerVersionRef])
|
|
382
|
+
hotel.tombstonedOfferRefs = unique([...hotel.tombstonedOfferRefs, offerRef])
|
|
383
|
+
}
|
|
384
|
+
hotel.currentGeneration.valid = false; hotel.invalidatedOfferRefs = [...hotel.currentGeneration.orderedOfferRefs]; hotel.currentOfferRefs = []; hotel.freshOffersRequired = hotel.checksIssued < MAX_OFFER_CHECKS_PER_HOTEL_V2 && hotel.offerQueriesIssued < MAX_OFFER_QUERIES_PER_HOTEL_V2
|
|
385
|
+
hotel.status = completeNegative ? 'negative' : 'inconclusive'; hotel.lastEvidence = completeNegative ? 'unavailable' : 'inconclusive'
|
|
386
|
+
const currentOrdinal = next.hotelRefs.indexOf(attempt.hotelRef)
|
|
387
|
+
const nextOrdinal = hotel.freshOffersRequired ? currentOrdinal : next.hotelRefs.findIndex((ref) => ['active', 'unvisited'].includes(next.hotels[ref]!.status))
|
|
388
|
+
if (nextOrdinal >= 0) next.activeHotelOrdinal = nextOrdinal
|
|
389
|
+
next.availabilityPhase = phaseForActiveHotel(next); return withTerminal(next)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Fold the availability portion of a receipt using the same pure reducer as
|
|
393
|
+
* continueWithReceipt and resumeTask. */
|
|
394
|
+
export function reduceAvailabilityReceiptV2(
|
|
395
|
+
state: AvailabilityPolicyStateV2,
|
|
396
|
+
workspace: BookingWorkspaceSnapshotV2,
|
|
397
|
+
receipt: ActionReceiptV2,
|
|
398
|
+
action: AvailabilityActionLikeV2,
|
|
399
|
+
): AvailabilityPolicyStateV2 {
|
|
400
|
+
let next = recordVisibleHotelsV2(state, workspace)
|
|
401
|
+
if (action.kind === 'offers.query' && !state.recoveryStarted) {
|
|
402
|
+
if (!isOfferCriteriaV2(action.input.criteria)) throw new Error('availability_criteria_required')
|
|
403
|
+
next = recordObservedOffersQueryV2(next, action.input.hotelRefs, digestV2(action.input.criteria), workspace, receipt, action.actionId, digestV2(receipt), action.input.criteria)
|
|
404
|
+
} else if (action.kind === 'offers.query' && state.recoveryStarted) {
|
|
405
|
+
if (!isOfferCriteriaV2(action.input.criteria)) throw new Error('availability_criteria_required')
|
|
406
|
+
next = recordOffersGenerationV2(next, action.input.hotelRefs, workspace, action.actionId, digestV2(receipt), receipt, state.criteriaDigest ?? '', action.input.criteria)
|
|
407
|
+
} else if (action.kind === 'offer.check') {
|
|
408
|
+
if (action.expectedRevision === undefined) throw new Error('availability_expected_revision_missing')
|
|
409
|
+
next = recordOfferCheckReceiptV2(next, workspace, receipt, action.actionId, action.input.offerRef, action.input.offerVersionRef, action.expectedRevision)
|
|
410
|
+
}
|
|
411
|
+
return next
|
|
412
|
+
}
|
|
413
|
+
export function availabilityPolicyIsTerminalV2(state: AvailabilityPolicyStateV2): boolean { return Boolean(state.terminal) }
|
|
414
|
+
export function availabilityPolicyResultV2(state: AvailabilityPolicyStateV2): AvailabilityExhaustionV2 | undefined { return state.terminal ? { ...state.terminal, hotelRefs: [...state.terminal.hotelRefs] } : undefined }
|
|
415
|
+
export function validateAvailabilityPolicyV2(state: AvailabilityPolicyStateV2): boolean {
|
|
416
|
+
try {
|
|
417
|
+
if (!state || typeof state.initialized !== 'boolean' || typeof state.recoveryStarted !== 'boolean') return false
|
|
418
|
+
if (state.recoveryId !== undefined && (typeof state.recoveryId !== 'string' || !state.recoveryId)) return false
|
|
419
|
+
if (state.criteriaDigest !== undefined && (typeof state.criteriaDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.criteriaDigest))) return false
|
|
420
|
+
if (state.candidateSetDigest !== undefined && (typeof state.candidateSetDigest !== 'string' || !/^[0-9a-f]{64}$/.test(state.candidateSetDigest))) return false
|
|
421
|
+
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 > MAX_HOTELS_PER_TASK_V2) return false
|
|
423
|
+
if (state.hotelRefs.some((ref) => typeof ref !== 'string' || !ref) || new Set(state.hotelRefs).size !== state.hotelRefs.length) return false
|
|
424
|
+
if ((state.hotelRefs.length && (state.activeHotelOrdinal < 0 || state.activeHotelOrdinal >= state.hotelRefs.length)) || (!state.hotelRefs.length && state.activeHotelOrdinal !== 0)) return false
|
|
425
|
+
if (!state.hotels || typeof state.hotels !== 'object' || Object.keys(state.hotels).sort().join('\0') !== [...state.hotelRefs].sort().join('\0')) return false
|
|
426
|
+
if (!Array.isArray(state.attempts) || !Array.isArray(state.queryReservations)) return false
|
|
427
|
+
if (!state.recoveryStarted) {
|
|
428
|
+
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
|
+
if (state.criteria !== undefined || state.criteriaDigest !== undefined) {
|
|
430
|
+
if (!isOfferCriteriaV2(state.criteria) || !state.criteriaDigest || state.criteriaDigest !== digestV2(state.criteria)) return false
|
|
431
|
+
}
|
|
432
|
+
if (state.lastQueryCriteriaDigest !== undefined && state.lastQueryCriteriaDigest !== state.criteriaDigest) return false
|
|
433
|
+
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
|
|
434
|
+
if (state.lastQueryOfferHotels !== undefined && (!state.lastQueryHotelRefs || Object.values(state.lastQueryOfferHotels).some((ref) => typeof ref !== 'string' || !state.lastQueryHotelRefs!.includes(ref)) || Object.keys(state.lastQueryOfferHotels).some((ref) => typeof ref !== 'string' || !ref))) return false
|
|
435
|
+
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
|
|
436
|
+
if (state.lastQuerySourceReceiptDigest !== undefined && state.lastQuerySourceActionId === undefined) return false
|
|
437
|
+
return state.initialized === false && state.availabilityPhase === 'need_offers'
|
|
438
|
+
}
|
|
439
|
+
if (!state.initialized || !state.recoveryId || !state.candidateSetDigest || state.candidateSetDigest !== digestV2(state.hotelRefs)) return false
|
|
440
|
+
const criteriaBound = isOfferCriteriaV2(state.criteria) && Boolean(state.criteriaDigest) && state.criteriaDigest === digestV2(state.criteria)
|
|
441
|
+
const manualCheckWorkspaceSnapshot = state.attempts.length > 0 && state.attempts.every((attempt) => state.hotels[attempt.hotelRef]?.currentGeneration?.source.kind === 'workspace_snapshot')
|
|
442
|
+
const manualCheckMayBindCriteria = !state.criteria && !state.criteriaDigest && state.queryReservations.length === 0 && manualCheckWorkspaceSnapshot && (
|
|
443
|
+
['waiting_check', 'need_offers'].includes(state.availabilityPhase)
|
|
444
|
+
|| state.availabilityPhase === 'need_check'
|
|
445
|
+
|| (state.availabilityPhase === 'terminal' && state.terminal?.code === 'availability_confirmed')
|
|
446
|
+
)
|
|
447
|
+
if (!criteriaBound && !manualCheckMayBindCriteria) return false
|
|
448
|
+
if (state.lastQueryCriteriaDigest !== undefined && state.lastQueryCriteriaDigest !== state.criteriaDigest) return false
|
|
449
|
+
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
|
|
450
|
+
if (state.lastQueryOfferHotels !== undefined && (!state.lastQueryHotelRefs || Object.values(state.lastQueryOfferHotels).some((ref) => typeof ref !== 'string' || !state.lastQueryHotelRefs!.includes(ref)) || Object.keys(state.lastQueryOfferHotels).some((ref) => typeof ref !== 'string' || !ref))) return false
|
|
451
|
+
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
|
+
if (state.lastQuerySourceReceiptDigest !== undefined && state.lastQuerySourceActionId === undefined) return false
|
|
453
|
+
if (state.hotelRefs.length === 0) return false
|
|
454
|
+
const validStatus = new Set<AvailabilityHotelStatusV2>(['unvisited', 'active', 'negative', 'confirmed', 'inconclusive'])
|
|
455
|
+
const validEvidence = new Set<AvailabilityHotelStateV2['lastEvidence']>(['none', 'confirmed', 'unavailable', 'inconclusive'])
|
|
456
|
+
for (const ref of state.hotelRefs) {
|
|
457
|
+
const hotel = state.hotels[ref]
|
|
458
|
+
if (!hotel || hotel.hotelRef !== ref || !validStatus.has(hotel.status) || !validEvidence.has(hotel.lastEvidence)) return false
|
|
459
|
+
if (![hotel.checksIssued, hotel.offerQueriesIssued, hotel.generationNo, hotel.generation, hotel.checkCount].every(Number.isSafeInteger)) return false
|
|
460
|
+
if (hotel.checksIssued < 0 || hotel.checksIssued > MAX_OFFER_CHECKS_PER_HOTEL_V2 || hotel.offerQueriesIssued < 0 || hotel.offerQueriesIssued > MAX_OFFER_QUERIES_PER_HOTEL_V2 || hotel.generationNo < 0 || hotel.generation !== hotel.generationNo || hotel.checkCount !== hotel.checksIssued) return false
|
|
461
|
+
if (typeof hotel.freshOffersRequired !== 'boolean' || !Array.isArray(hotel.currentOfferRefs) || !Array.isArray(hotel.invalidatedOfferRefs) || !Array.isArray(hotel.tombstonedOfferRefs) || !Array.isArray(hotel.tombstonedOfferVersionRefs)) return false
|
|
462
|
+
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 > MAX_OFFERS_PER_HOTEL_GENERATION_V2 || hotel.invalidatedOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2 || hotel.currentOfferRefs.some((offer) => hotel.invalidatedOfferRefs.includes(offer) || hotel.tombstonedOfferRefs.includes(offer))) return false
|
|
464
|
+
const generation = hotel.currentGeneration
|
|
465
|
+
if (hotel.generationNo === 0) {
|
|
466
|
+
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
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
if (!generation || generation.generationId !== `${ref}:generation:${hotel.generationNo}` || !/^[0-9a-f]{64}$/.test(generation.offerSetDigest) || generation.offerSetDigest !== digestV2(generation.orderedOfferRefs) || !Array.isArray(generation.orderedOfferRefs) || generation.orderedOfferRefs.length > MAX_OFFERS_PER_HOTEL_GENERATION_V2 || 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
|
|
470
|
+
if (generation.offerSetDigest !== digestV2(generation.orderedOfferRefs) || hotel.currentOfferRefs.some((offer) => !generation.orderedOfferRefs.includes(offer)) || hotel.invalidatedOfferRefs.some((offer) => !generation.orderedOfferRefs.includes(offer))) return false
|
|
471
|
+
const source = generation.source
|
|
472
|
+
if (!source || !['query_receipt', 'workspace_snapshot'].includes(source.kind) || !Number.isSafeInteger(source.workspaceRevision) || source.workspaceRevision < 0) return false
|
|
473
|
+
if (source.kind === 'query_receipt' && (typeof source.actionId !== 'string' || !source.actionId || !/^[0-9a-f]{64}$/.test(source.receiptDigest))) return false
|
|
474
|
+
if (source.kind === 'workspace_snapshot' && (typeof source.workspaceDigest !== 'string' || !/^[0-9a-f]{64}$/.test(source.workspaceDigest))) return false
|
|
475
|
+
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 >= MAX_OFFER_CHECKS_PER_HOTEL_V2 || hotel.offerQueriesIssued >= MAX_OFFER_QUERIES_PER_HOTEL_V2)) return false
|
|
477
|
+
if (hotel.status === 'unvisited' || hotel.status === 'negative') {
|
|
478
|
+
if (hotel.status === 'unvisited' || hotel.lastEvidence !== 'unavailable' || hotel.currentOfferRefs.length || generation.valid) return false
|
|
479
|
+
}
|
|
480
|
+
if (hotel.status === 'active' && (!hotel.currentOfferRefs.length || hotel.lastEvidence !== 'none' || hotel.freshOffersRequired)) return false
|
|
481
|
+
if (hotel.status === 'confirmed' && (hotel.lastEvidence !== 'confirmed' || !hotel.currentOfferRefs.length || generation.valid)) return false
|
|
482
|
+
if (hotel.status === 'inconclusive' && (hotel.lastEvidence !== 'inconclusive' || hotel.currentOfferRefs.length || generation.valid)) return false
|
|
483
|
+
}
|
|
484
|
+
const attempts = state.attempts
|
|
485
|
+
if (new Set(attempts.map((attempt) => attempt.actionId)).size !== attempts.length) return false
|
|
486
|
+
const attemptsByHotel = new Map<string, AvailabilityAttemptV2[]>()
|
|
487
|
+
for (const attempt of attempts) {
|
|
488
|
+
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
|
+
const hotel = state.hotels[attempt.hotelRef]!
|
|
490
|
+
const generation = hotel.currentGeneration
|
|
491
|
+
if (!generation || attempt.generation < 1 || attempt.generation > hotel.generationNo || attempt.ordinal < 1 || attempt.ordinal > hotel.checksIssued || (attempt.generation === hotel.generationNo && !generation.orderedOfferRefs.includes(attempt.offerRef))) return false
|
|
492
|
+
const list = attemptsByHotel.get(attempt.hotelRef) ?? []; list.push(attempt); attemptsByHotel.set(attempt.hotelRef, list)
|
|
493
|
+
}
|
|
494
|
+
for (const [ref, attemptsForHotel] of attemptsByHotel) {
|
|
495
|
+
const ordinals = attemptsForHotel.map((attempt) => attempt.ordinal)
|
|
496
|
+
if (new Set(ordinals).size !== ordinals.length || attemptsForHotel.length !== state.hotels[ref]!.checksIssued || new Set(attemptsForHotel.map((attempt) => `${attempt.generation}:${attempt.offerRef}:${attempt.offerVersionRef}`)).size !== attemptsForHotel.length) return false
|
|
497
|
+
}
|
|
498
|
+
for (const ref of state.hotelRefs) if ((attemptsByHotel.get(ref)?.length ?? 0) !== state.hotels[ref]!.checksIssued) return false
|
|
499
|
+
if (new Set(state.queryReservations.map((reservation) => reservation.actionId)).size !== state.queryReservations.length) return false
|
|
500
|
+
const reservedHotels = new Set<string>()
|
|
501
|
+
for (const reservation of state.queryReservations) {
|
|
502
|
+
if (typeof reservation.actionId !== 'string' || !reservation.actionId || !Array.isArray(reservation.hotelRefs) || !reservation.hotelRefs.length || reservation.hotelRefs.length > MAX_HOTELS_PER_TASK_V2 || 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
|
+
reservation.hotelRefs.forEach((ref) => reservedHotels.add(ref))
|
|
504
|
+
}
|
|
505
|
+
if (state.availabilityPhase === 'waiting_offers' && state.queryReservations.length === 0) return false
|
|
506
|
+
if (state.availabilityPhase !== 'waiting_offers' && state.queryReservations.length > 0) return false
|
|
507
|
+
if (state.availabilityPhase === 'waiting_check' && !attempts.length) return false
|
|
508
|
+
if (state.availabilityPhase === 'need_check') {
|
|
509
|
+
const active = state.hotels[state.hotelRefs[state.activeHotelOrdinal]!]!
|
|
510
|
+
if (active.status !== 'active' || !active.currentGeneration?.valid || !active.currentOfferRefs.length || active.freshOffersRequired) return false
|
|
511
|
+
}
|
|
512
|
+
if (state.availabilityPhase === 'terminal' && !state.terminal) return false
|
|
513
|
+
if (state.availabilityPhase !== 'terminal' && state.terminal) return false
|
|
514
|
+
const recomputedTerminal = terminalFor(state)
|
|
515
|
+
if (state.terminal) {
|
|
516
|
+
if (!recomputedTerminal || digestV2(state.terminal) !== digestV2(recomputedTerminal)) return false
|
|
517
|
+
} else if (recomputedTerminal && !['waiting_offers', 'waiting_check'].includes(state.availabilityPhase)) return false
|
|
518
|
+
return true
|
|
519
|
+
} catch {
|
|
520
|
+
return false
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
export function availabilityPolicyActionIsRelevantV2(action: BookingReadActionV2): boolean { return action.kind === 'offers.query' || action.kind === 'offer.check' }
|