@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,247 @@
|
|
|
1
|
+
/** Request adapter for the unified Booking Copilot HTTP/SSE server. */
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
import {
|
|
5
|
+
BOOKING_SURFACE_SCHEMA_V2_SHA256,
|
|
6
|
+
BOOKING_SURFACE_SCHEMA_VERSION_V2,
|
|
7
|
+
BOOKING_COPILOT_V2_ACCEPTED_TURN_KINDS,
|
|
8
|
+
type BookingCopilotV2IngressMode,
|
|
9
|
+
BOOKING_READ_ACTION_KINDS_V2,
|
|
10
|
+
BOOKING_SURFACE_ALLOWED_ACTIONS_V2,
|
|
11
|
+
type BookingCopilotTurnV2,
|
|
12
|
+
type BookingIngressBindingV2,
|
|
13
|
+
type BookingIngressPrincipalV2,
|
|
14
|
+
type IngressTurnV2,
|
|
15
|
+
type UserTurnV2,
|
|
16
|
+
type BookingSurfaceEventV2,
|
|
17
|
+
type BookingWorkspaceSnapshotV2,
|
|
18
|
+
} from './contracts-v2.ts'
|
|
19
|
+
import {
|
|
20
|
+
BookingCopilotTaskRuntimeV2,
|
|
21
|
+
type BookingPlannerSessionFactoryV2,
|
|
22
|
+
type BookingCopilotTaskStateV2,
|
|
23
|
+
type BookingIngressRequestBindingInputV2,
|
|
24
|
+
} from './runtime-v2.ts'
|
|
25
|
+
import { validateBookingSurfaceV2 } from './validation-v2.ts'
|
|
26
|
+
import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.ts'
|
|
27
|
+
|
|
28
|
+
export interface BookingCopilotV2Adapter {
|
|
29
|
+
runtime: BookingCopilotTaskRuntimeV2
|
|
30
|
+
plannerFactory: BookingPlannerSessionFactoryV2
|
|
31
|
+
/** Optional BFF-owned identity binding. GoTry never accepts browser identity. */
|
|
32
|
+
ingressBinding?: BookingIngressBindingV2
|
|
33
|
+
/** Principal authenticated by the HotelByte BFF, never by browser input. */
|
|
34
|
+
principal?: BookingIngressPrincipalV2
|
|
35
|
+
/** Explicit process composition mode; omitted means infer from a complete pair. */
|
|
36
|
+
ingressMode?: BookingCopilotV2IngressMode
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sessionsByAdapter = new WeakMap<BookingCopilotV2Adapter, Map<string, ReturnType<BookingPlannerSessionFactoryV2>>>()
|
|
40
|
+
const decisionFlightsByAdapter = new WeakMap<BookingCopilotV2Adapter, Map<string, Promise<BookingSurfaceEventV2[]>>>()
|
|
41
|
+
function sessionsFor(adapter: BookingCopilotV2Adapter): Map<string, ReturnType<BookingPlannerSessionFactoryV2>> {
|
|
42
|
+
let sessions = sessionsByAdapter.get(adapter)
|
|
43
|
+
if (!sessions) { sessions = new Map(); sessionsByAdapter.set(adapter, sessions) }
|
|
44
|
+
return sessions
|
|
45
|
+
}
|
|
46
|
+
function decisionFlightsFor(adapter: BookingCopilotV2Adapter): Map<string, Promise<BookingSurfaceEventV2[]>> {
|
|
47
|
+
let flights = decisionFlightsByAdapter.get(adapter)
|
|
48
|
+
if (!flights) { flights = new Map(); decisionFlightsByAdapter.set(adapter, flights) }
|
|
49
|
+
return flights
|
|
50
|
+
}
|
|
51
|
+
async function runDecisionSingleFlight(adapter: BookingCopilotV2Adapter, key: string, work: () => Promise<BookingSurfaceEventV2[]>): Promise<BookingSurfaceEventV2[]> {
|
|
52
|
+
const flights = decisionFlightsFor(adapter)
|
|
53
|
+
const prior = flights.get(key)
|
|
54
|
+
if (prior) return prior
|
|
55
|
+
const current = work()
|
|
56
|
+
flights.set(key, current)
|
|
57
|
+
try { return await current } finally { if (flights.get(key) === current) flights.delete(key) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function send(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)) }
|
|
61
|
+
function readBody(req: IncomingMessage, max: number): Promise<string> {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const chunks: Buffer[] = []; let size = 0; let tooLarge = false
|
|
64
|
+
req.on('data', (chunk: Buffer) => { size += chunk.length; if (size > max) tooLarge = true; else chunks.push(chunk) })
|
|
65
|
+
req.on('end', () => tooLarge ? reject(new Error('payload_too_large')) : resolve(Buffer.concat(chunks).toString('utf8')))
|
|
66
|
+
req.on('error', reject)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
function write(res: ServerResponse, event: BookingSurfaceEventV2): void { res.write(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`) }
|
|
70
|
+
export { normalizeBookingErrorCode as bookingPlannerErrorCode, safeBookingErrorMessage as bookingPlannerErrorMessage } from './error-codes.ts'
|
|
71
|
+
|
|
72
|
+
function sameCapabilities(a: readonly string[], b: readonly string[]): boolean {
|
|
73
|
+
return a.length === b.length && [...a].sort().every((kind, index) => kind === [...b].sort()[index])
|
|
74
|
+
}
|
|
75
|
+
function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
76
|
+
return Object.keys(value).length === allowed.length && Object.keys(value).every((key) => allowed.includes(key))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SURFACE_ACTION_MATRIX: Record<BookingWorkspaceSnapshotV2['surface'], readonly string[]> = BOOKING_SURFACE_ALLOWED_ACTIONS_V2
|
|
80
|
+
|
|
81
|
+
function validSurfaceActions(surface: unknown, allowed: unknown): allowed is BookingWorkspaceSnapshotV2['capabilities']['allowedActions'] {
|
|
82
|
+
if (typeof surface !== 'string' || !Object.prototype.hasOwnProperty.call(SURFACE_ACTION_MATRIX, surface)) return false
|
|
83
|
+
if (!Array.isArray(allowed) || allowed.length < 1 || new Set(allowed).size !== allowed.length) return false
|
|
84
|
+
const matrix = SURFACE_ACTION_MATRIX[surface as BookingWorkspaceSnapshotV2['surface']]
|
|
85
|
+
return allowed.every((action) => typeof action === 'string'
|
|
86
|
+
&& matrix.includes(action)
|
|
87
|
+
&& (BOOKING_READ_ACTION_KINDS_V2 as readonly string[]).includes(action))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validBoundWorkspaceAuthority(workspace: BookingWorkspaceSnapshotV2): boolean {
|
|
91
|
+
return workspace.capabilities.surface === workspace.surface
|
|
92
|
+
&& validSurfaceActions(workspace.surface, workspace.capabilities.allowedActions)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validIngressBinding(binding: unknown, ingress: IngressTurnV2): binding is {
|
|
96
|
+
taskId: string
|
|
97
|
+
turnId: string
|
|
98
|
+
contextRef: string
|
|
99
|
+
surface: BookingWorkspaceSnapshotV2['surface']
|
|
100
|
+
allowedActions: BookingWorkspaceSnapshotV2['capabilities']['allowedActions']
|
|
101
|
+
} {
|
|
102
|
+
if (!binding || typeof binding !== 'object' || Array.isArray(binding)) return false
|
|
103
|
+
const candidate = binding as Record<string, unknown>
|
|
104
|
+
if (!exactKeys(candidate, ['taskId', 'turnId', 'contextRef', 'surface', 'allowedActions'])) return false
|
|
105
|
+
if (typeof candidate.taskId !== 'string' || typeof candidate.turnId !== 'string' || typeof candidate.contextRef !== 'string') return false
|
|
106
|
+
if (typeof candidate.surface !== 'string' || candidate.surface !== ingress.surfaceHint || !Object.prototype.hasOwnProperty.call(SURFACE_ACTION_MATRIX, candidate.surface)) return false
|
|
107
|
+
return validSurfaceActions(candidate.surface, candidate.allowedActions)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fallbackErrorEvent(task: BookingCopilotTaskStateV2, code: string): BookingSurfaceEventV2 {
|
|
111
|
+
return {
|
|
112
|
+
schemaVersion: BOOKING_SURFACE_SCHEMA_VERSION_V2,
|
|
113
|
+
eventId: `error-${task.taskId}-${task.lastSequence + 1}`,
|
|
114
|
+
taskId: task.taskId,
|
|
115
|
+
contextRef: task.contextRef,
|
|
116
|
+
sequence: task.lastSequence + 1,
|
|
117
|
+
emittedAt: new Date().toISOString(),
|
|
118
|
+
kind: 'error',
|
|
119
|
+
error: { code: normalizeBookingErrorCode(code), message: safeBookingErrorMessage(normalizeBookingErrorCode(code)), retryable: false },
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeTypedError(res: ServerResponse, adapter: BookingCopilotV2Adapter, task: BookingCopilotTaskStateV2, error: unknown, requestKey?: string, includeSubmitted = false): void {
|
|
124
|
+
const code = normalizeBookingErrorCode(error)
|
|
125
|
+
try {
|
|
126
|
+
const decision = { kind: 'error' as const, error: { code, message: safeBookingErrorMessage(code), retryable: false } }
|
|
127
|
+
if (requestKey) {
|
|
128
|
+
const events = adapter.runtime.applyDecisionBatch(task.taskId, requestKey, [decision], includeSubmitted)
|
|
129
|
+
for (const event of events) write(res, event)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
write(res, adapter.runtime.emitEvent(task.taskId, decision))
|
|
133
|
+
} catch {
|
|
134
|
+
// The stream is already committed. Emit a schema-valid, non-durable
|
|
135
|
+
// typed envelope instead of silently returning an empty HTTP 200 stream.
|
|
136
|
+
write(res, fallbackErrorEvent(task, code))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Handles one already-authenticated v2 request inside server.ts's sole listener. */
|
|
141
|
+
export async function handleBookingCopilotV2Request(req: IncomingMessage, res: ServerResponse, adapter: BookingCopilotV2Adapter, maxBodyBytes: number): Promise<void> {
|
|
142
|
+
let turn: Exclude<BookingCopilotTurnV2, IngressTurnV2>
|
|
143
|
+
let browserRequestKey: string | undefined
|
|
144
|
+
let requestBinding: BookingIngressRequestBindingInputV2 | undefined
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(await readBody(req, maxBodyBytes)) as unknown
|
|
147
|
+
const valid = validateBookingSurfaceV2(parsed)
|
|
148
|
+
if (!valid.ok) { send(res, 400, { error: { code: 'invalid_booking_surface_turn', details: valid.errors } }); return }
|
|
149
|
+
if ((parsed as BookingCopilotTurnV2).kind === 'user.turn.ingress') {
|
|
150
|
+
const ingress = parsed as IngressTurnV2
|
|
151
|
+
browserRequestKey = ingress.requestKey
|
|
152
|
+
if (adapter.ingressMode !== 'bff-ingress-binding' || !adapter.ingressBinding || !adapter.principal) {
|
|
153
|
+
send(res, 503, { error: { code: 'trusted_ingress_binding_required', mode: adapter.ingressMode ?? 'bff-bound-turn-only', acceptedTurnKinds: [...BOOKING_COPILOT_V2_ACCEPTED_TURN_KINDS] } })
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const binding = await adapter.ingressBinding.bind(ingress, adapter.principal)
|
|
157
|
+
if (!validIngressBinding(binding, ingress)) { send(res, 502, { error: { code: 'invalid_ingress_binding' } }); return }
|
|
158
|
+
const boundWorkspace = {
|
|
159
|
+
...ingress.workspace,
|
|
160
|
+
contextRef: binding.contextRef,
|
|
161
|
+
surface: binding.surface,
|
|
162
|
+
capabilities: { surface: binding.surface, allowedActions: [...binding.allowedActions] },
|
|
163
|
+
} as BookingWorkspaceSnapshotV2
|
|
164
|
+
turn = { schemaVersion: 'booking.surface.v2', kind: 'user.turn', taskId: binding.taskId, turnId: binding.turnId, workspace: boundWorkspace, request: ingress.request }
|
|
165
|
+
requestBinding = { requestKey: ingress.requestKey, principal: adapter.principal, ...(ingress.taskHandle ? { taskHandle: ingress.taskHandle } : {}) }
|
|
166
|
+
const boundValid = validateBookingSurfaceV2(turn)
|
|
167
|
+
if (!boundValid.ok) { send(res, 502, { error: { code: 'invalid_ingress_binding', details: boundValid.errors } }); return }
|
|
168
|
+
} else {
|
|
169
|
+
turn = parsed as Exclude<BookingCopilotTurnV2, IngressTurnV2>
|
|
170
|
+
if (!validBoundWorkspaceAuthority(turn.workspace)) {
|
|
171
|
+
send(res, 403, { error: { code: 'invalid_bound_turn_authority' } })
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (error) { const code = normalizeBookingErrorCode(error); send(res, code === 'PAYLOAD_TOO_LARGE' ? 413 : 400, { error: { code } }); return }
|
|
176
|
+
|
|
177
|
+
let task: BookingCopilotTaskStateV2; let fresh = false; let replayKey: string | undefined; let replayEvents: BookingSurfaceEventV2[] | null = null; let activeDecisionKey: string | undefined
|
|
178
|
+
try {
|
|
179
|
+
fresh = turn.kind === 'action.receipt.continuation' ? false : !turn.taskId || adapter.runtime.resumeTask(turn.taskId) === null
|
|
180
|
+
if (turn.kind === 'action.receipt.continuation') {
|
|
181
|
+
replayKey = `receipt:${turn.receipt.actionId}:${adapter.runtime.receiptDigest(turn.receipt)}`
|
|
182
|
+
task = adapter.runtime.continueWithReceipt(turn)
|
|
183
|
+
if (task.awaitingApproval) {
|
|
184
|
+
replayKey = adapter.runtime.approvalPresentationRequestKey(turn.taskId)
|
|
185
|
+
}
|
|
186
|
+
replayEvents = adapter.runtime.readDecisionBatch(turn.taskId, replayKey)
|
|
187
|
+
if (!replayEvents && task.phase === 'terminal') replayEvents = adapter.runtime.terminalDecisionBatch(turn.taskId, replayKey)
|
|
188
|
+
} else {
|
|
189
|
+
const existing = turn.taskId ? adapter.runtime.resumeTask(turn.taskId) : null
|
|
190
|
+
if (requestBinding && turn.kind === 'user.turn') adapter.runtime.assertRequestBinding(browserRequestKey!, turn, requestBinding)
|
|
191
|
+
const requestReplayKey = existing && browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : undefined
|
|
192
|
+
const directReplayKey = existing && turn.kind === 'user.turn' && turn.turnId ? `turn:${existing.taskId}:${turn.turnId}` : undefined
|
|
193
|
+
const stableReplayKey = requestReplayKey ?? directReplayKey
|
|
194
|
+
const stableReplay = stableReplayKey ? adapter.runtime.readDecisionBatch(existing!.taskId, stableReplayKey) : null
|
|
195
|
+
if (stableReplay) {
|
|
196
|
+
if (existing && turn.kind === 'user.turn' && directReplayKey) adapter.runtime.assertTurnBinding(existing.taskId, turn)
|
|
197
|
+
task = existing!
|
|
198
|
+
replayKey = stableReplayKey
|
|
199
|
+
replayEvents = stableReplay
|
|
200
|
+
} else if (existing?.phase === 'terminal' || existing?.phase === 'error') {
|
|
201
|
+
throw new Error('task_terminal')
|
|
202
|
+
} else if (existing?.phase === 'waiting_receipt') {
|
|
203
|
+
const workspace = turn.workspace as BookingWorkspaceSnapshotV2
|
|
204
|
+
if (workspace.contextRef !== existing.contextRef || workspace.surface !== existing.surface || workspace.revision !== existing.revision || workspace.capabilities.surface !== existing.surface || !sameCapabilities(workspace.capabilities.allowedActions, existing.allowedActions)) throw new Error('task_conflict:workspace_mismatch')
|
|
205
|
+
// A waiting task can only be replayed with the caller's stable turn
|
|
206
|
+
// identity. Without it, an identical sentence is not distinguishable
|
|
207
|
+
// from a new user turn and must not receive an old operation batch.
|
|
208
|
+
if (!turn.turnId || existing.lastTurnId !== turn.turnId) throw new Error('receipt_required')
|
|
209
|
+
replayKey = browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : `turn:${existing.taskId}:${existing.lastTurnId ?? existing.userTurnCount}`
|
|
210
|
+
replayEvents = adapter.runtime.readDecisionBatch(existing.taskId, replayKey)
|
|
211
|
+
if (!replayEvents) throw new Error('receipt_required')
|
|
212
|
+
task = existing
|
|
213
|
+
} else {
|
|
214
|
+
task = adapter.runtime.startTask(turn, requestBinding)
|
|
215
|
+
if (turn.kind === 'user.turn' && turn.taskId) {
|
|
216
|
+
replayKey = browserRequestKey ? `ingress:${task.taskId}:${browserRequestKey}` : `turn:${task.taskId}:${turn.turnId ?? task.userTurnCount}`
|
|
217
|
+
replayEvents = adapter.runtime.readDecisionBatch(task.taskId, replayKey)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (error) { send(res, 409, { error: { code: normalizeBookingErrorCode(error) } }); return }
|
|
222
|
+
|
|
223
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive', 'x-content-type-options': 'nosniff', 'x-booking-surface-version': BOOKING_SURFACE_SCHEMA_VERSION_V2, 'x-booking-surface-schema-sha256': BOOKING_SURFACE_SCHEMA_V2_SHA256 })
|
|
224
|
+
try {
|
|
225
|
+
if (replayEvents) {
|
|
226
|
+
for (const event of replayEvents) write(res, event)
|
|
227
|
+
} else {
|
|
228
|
+
const requestKey = replayKey ?? `turn:${task.taskId}:${task.userTurnCount}`
|
|
229
|
+
activeDecisionKey = requestKey
|
|
230
|
+
const flightKey = JSON.stringify([task.taskId, requestKey])
|
|
231
|
+
const decisionEvents = await runDecisionSingleFlight(adapter, flightKey, async () => {
|
|
232
|
+
if (turn.kind === 'action.receipt.continuation' && task.awaitingApproval) {
|
|
233
|
+
return adapter.runtime.applyDecisionBatch(task.taskId, requestKey, [adapter.runtime.approvalQuestion(task.taskId)], false)
|
|
234
|
+
}
|
|
235
|
+
const sessions = sessionsFor(adapter)
|
|
236
|
+
const session = sessions.get(task.taskId) ?? adapter.plannerFactory(task)
|
|
237
|
+
sessions.set(task.taskId, session)
|
|
238
|
+
const decisions = await session.next({ turn, task: adapter.runtime.resumeTask(task.taskId) ?? task })
|
|
239
|
+
const plannerDecisions = decisions.length ? decisions : [{ kind: 'error' as const, error: { code: 'PLANNER_NO_DECISION', message: 'Planner returned no typed decision.', retryable: false } }]
|
|
240
|
+
return adapter.runtime.applyDecisionBatch(task.taskId, requestKey, plannerDecisions, fresh)
|
|
241
|
+
})
|
|
242
|
+
for (const event of decisionEvents) write(res, event)
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
writeTypedError(res, adapter, task, error, activeDecisionKey, fresh)
|
|
246
|
+
} finally { res.end() }
|
|
247
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BFF-only HTTP/SSE seam for the embedded Booking Copilot.
|
|
3
|
+
*
|
|
4
|
+
* The browser never calls this server directly. A same-origin HotelByte BFF
|
|
5
|
+
* authenticates the actor, mints contextRef and sends a strict planner turn
|
|
6
|
+
* with a deployment-level Bearer key. No portal token field exists here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
10
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
11
|
+
import {
|
|
12
|
+
BOOKING_SURFACE_SCHEMA_SHA256,
|
|
13
|
+
BOOKING_SURFACE_SCHEMA_SHA256_HEADER,
|
|
14
|
+
BOOKING_SURFACE_SCHEMA_VERSION,
|
|
15
|
+
BOOKING_SURFACE_VERSION_HEADER,
|
|
16
|
+
type BookingCopilotTurnV1,
|
|
17
|
+
type BookingReadActionV1,
|
|
18
|
+
type BookingSurfaceEventV1,
|
|
19
|
+
} from './contracts.ts'
|
|
20
|
+
import {
|
|
21
|
+
BookingCopilotTaskRuntime,
|
|
22
|
+
type BookingCopilotTaskStateV1,
|
|
23
|
+
type BookingSurfaceEventDraftV1,
|
|
24
|
+
} from './runtime.ts'
|
|
25
|
+
import { validateBookingCopilotTurnV1 } from './validation.ts'
|
|
26
|
+
import { handleBookingCopilotV2Request, type BookingCopilotV2Adapter } from './server-v2.ts'
|
|
27
|
+
import { normalizeBookingErrorCode, safeBookingErrorMessage } from './error-codes.ts'
|
|
28
|
+
import {
|
|
29
|
+
BOOKING_COPILOT_V2_ACCEPTED_TURN_KINDS,
|
|
30
|
+
BOOKING_COPILOT_V2_INGRESS_TURN_KIND,
|
|
31
|
+
BOOKING_COPILOT_V2_INGRESS_MODES,
|
|
32
|
+
BOOKING_SURFACE_SCHEMA_V2_SHA256,
|
|
33
|
+
BOOKING_SURFACE_SCHEMA_VERSION_V2,
|
|
34
|
+
type BookingCopilotV2IngressMode,
|
|
35
|
+
} from './contracts-v2.ts'
|
|
36
|
+
|
|
37
|
+
export type BookingPlannerDecisionV1 =
|
|
38
|
+
| { kind: 'operation'; action: BookingReadActionV1 }
|
|
39
|
+
| BookingSurfaceEventDraftV1
|
|
40
|
+
|
|
41
|
+
export interface BookingPlannerSessionV1 {
|
|
42
|
+
next(input: {
|
|
43
|
+
turn: BookingCopilotTurnV1
|
|
44
|
+
task: BookingCopilotTaskStateV1
|
|
45
|
+
}): Promise<readonly BookingPlannerDecisionV1[]>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Called once per task in a server process, including once after recovery. */
|
|
49
|
+
export type BookingPlannerSessionFactoryV1 = (
|
|
50
|
+
initialTask: BookingCopilotTaskStateV1,
|
|
51
|
+
) => BookingPlannerSessionV1
|
|
52
|
+
|
|
53
|
+
export interface BookingCopilotServerOptionsV1 {
|
|
54
|
+
/** Deployment credential shared only with the HotelByte BFF. */
|
|
55
|
+
apiKey: string
|
|
56
|
+
runtime?: BookingCopilotTaskRuntime
|
|
57
|
+
plannerFactory?: BookingPlannerSessionFactoryV1
|
|
58
|
+
host?: string
|
|
59
|
+
port?: number
|
|
60
|
+
maxBodyBytes?: number
|
|
61
|
+
/** GoTry source commit loaded from the root-owned release artifact. */
|
|
62
|
+
artifactId?: string
|
|
63
|
+
/** Optional v2 adapter dispatched by the same listener after version/hash selection. */
|
|
64
|
+
v2?: BookingCopilotV2Adapter
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface BookingCopilotServerHandleV1 {
|
|
68
|
+
server: Server
|
|
69
|
+
port: number
|
|
70
|
+
close(): Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface BookingCopilotRuntimeIdentityV1 {
|
|
74
|
+
nodeVersion: string
|
|
75
|
+
nodeModulesAbi: string
|
|
76
|
+
releaseTuple: string
|
|
77
|
+
glibcVersion: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runtimeIdentity(): BookingCopilotRuntimeIdentityV1 {
|
|
81
|
+
const report = process.report?.getReport?.() as { header?: { glibcVersionRuntime?: string } } | undefined
|
|
82
|
+
const header = report?.header
|
|
83
|
+
const glibcVersion = header?.glibcVersionRuntime ?? ''
|
|
84
|
+
return {
|
|
85
|
+
nodeVersion: process.version,
|
|
86
|
+
nodeModulesAbi: process.versions.modules ?? '',
|
|
87
|
+
releaseTuple: `${process.platform}-${process.arch}-${glibcVersion ? 'glibc' : 'unknown'}`,
|
|
88
|
+
glibcVersion,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeSecretEqual(actual: string, expected: string): boolean {
|
|
93
|
+
const actualBytes = Buffer.from(actual)
|
|
94
|
+
const expectedBytes = Buffer.from(expected)
|
|
95
|
+
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
99
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
100
|
+
res.end(JSON.stringify(body))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readBody(req: IncomingMessage, maxBodyBytes: number): Promise<string> {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const chunks: Buffer[] = []
|
|
106
|
+
let size = 0
|
|
107
|
+
let exceeded = false
|
|
108
|
+
req.on('data', (chunk: Buffer) => {
|
|
109
|
+
size += chunk.length
|
|
110
|
+
if (size > maxBodyBytes) {
|
|
111
|
+
exceeded = true
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
chunks.push(chunk)
|
|
115
|
+
})
|
|
116
|
+
req.on('end', () => exceeded ? reject(new Error('payload_too_large')) : resolve(Buffer.concat(chunks).toString('utf8')))
|
|
117
|
+
req.on('error', reject)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeSse(res: ServerResponse, event: BookingSurfaceEventV1): void {
|
|
122
|
+
res.write(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function startBookingCopilotServer(options: BookingCopilotServerOptionsV1): Promise<BookingCopilotServerHandleV1> {
|
|
126
|
+
if (!options.apiKey) return Promise.reject(new Error('booking_copilot_api_key_required'))
|
|
127
|
+
if (options.artifactId !== undefined && !/^[0-9a-f]{40}$/.test(options.artifactId)) {
|
|
128
|
+
return Promise.reject(new Error('booking_copilot_artifact_id_invalid'))
|
|
129
|
+
}
|
|
130
|
+
const hasIngressBinding = Boolean(options.v2?.ingressBinding)
|
|
131
|
+
const hasPrincipal = Boolean(options.v2?.principal)
|
|
132
|
+
if (options.v2 && hasIngressBinding !== hasPrincipal) {
|
|
133
|
+
return Promise.reject(new Error('booking_copilot_v2_ingress_binding_pair_required'))
|
|
134
|
+
}
|
|
135
|
+
if (options.v2 && hasIngressBinding && (typeof options.v2.ingressBinding?.bind !== 'function'
|
|
136
|
+
|| typeof options.v2.principal?.subject !== 'string' || options.v2.principal.subject.length === 0 || options.v2.principal.subject.length > 256
|
|
137
|
+
|| typeof options.v2.principal?.scope !== 'string' || options.v2.principal.scope.length === 0 || options.v2.principal.scope.length > 256)) {
|
|
138
|
+
return Promise.reject(new Error('booking_copilot_v2_ingress_binding_invalid'))
|
|
139
|
+
}
|
|
140
|
+
const ingressMode: BookingCopilotV2IngressMode | undefined = options.v2
|
|
141
|
+
? options.v2.ingressMode ?? (hasIngressBinding ? 'bff-ingress-binding' : 'bff-bound-turn-only')
|
|
142
|
+
: undefined
|
|
143
|
+
if (options.v2 && !BOOKING_COPILOT_V2_INGRESS_MODES.includes(ingressMode!)) {
|
|
144
|
+
return Promise.reject(new Error('booking_copilot_v2_ingress_mode_invalid'))
|
|
145
|
+
}
|
|
146
|
+
if (options.v2 && ingressMode === 'bff-ingress-binding' && !hasIngressBinding) {
|
|
147
|
+
return Promise.reject(new Error('booking_copilot_v2_ingress_binding_required'))
|
|
148
|
+
}
|
|
149
|
+
if (options.v2 && ingressMode === 'bff-bound-turn-only' && hasIngressBinding) {
|
|
150
|
+
return Promise.reject(new Error('booking_copilot_v2_ingress_mode_conflict'))
|
|
151
|
+
}
|
|
152
|
+
const v2 = options.v2 ? { ...options.v2, ingressMode } : undefined
|
|
153
|
+
const v2Only = Boolean(v2 && !options.runtime && !options.plannerFactory)
|
|
154
|
+
const sessions = new Map<string, BookingPlannerSessionV1>()
|
|
155
|
+
const maxBodyBytes = options.maxBodyBytes ?? 1_000_000
|
|
156
|
+
const runningIdentity = runtimeIdentity()
|
|
157
|
+
|
|
158
|
+
const server = createServer(async (req, res) => {
|
|
159
|
+
const isProbe = req.method === 'GET' && (req.url === '/healthz' || req.url === '/status')
|
|
160
|
+
if (!isProbe && (req.method !== 'POST' || req.url !== '/a2a/booking-copilot/turn')) {
|
|
161
|
+
sendJson(res, 404, { error: { code: 'not_found' } })
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const auth = String(req.headers.authorization ?? '')
|
|
165
|
+
if (!safeSecretEqual(auth, `Bearer ${options.apiKey}`)) {
|
|
166
|
+
sendJson(res, 401, { error: { code: 'unauthorized' } })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (isProbe) {
|
|
170
|
+
const activeV2Only = Boolean(v2 && !options.runtime && !options.plannerFactory)
|
|
171
|
+
res.setHeader(BOOKING_SURFACE_VERSION_HEADER, activeV2Only ? BOOKING_SURFACE_SCHEMA_VERSION_V2 : BOOKING_SURFACE_SCHEMA_VERSION)
|
|
172
|
+
res.setHeader(BOOKING_SURFACE_SCHEMA_SHA256_HEADER, activeV2Only ? BOOKING_SURFACE_SCHEMA_V2_SHA256 : BOOKING_SURFACE_SCHEMA_SHA256)
|
|
173
|
+
if (options.artifactId) res.setHeader('X-GoTry-Artifact-ID', options.artifactId)
|
|
174
|
+
res.setHeader('X-GoTry-Node-Version', runningIdentity.nodeVersion)
|
|
175
|
+
res.setHeader('X-GoTry-Node-Modules-ABI', runningIdentity.nodeModulesAbi)
|
|
176
|
+
res.setHeader('X-GoTry-Release-Tuple', runningIdentity.releaseTuple)
|
|
177
|
+
if (runningIdentity.glibcVersion) res.setHeader('X-GoTry-Glibc-Version', runningIdentity.glibcVersion)
|
|
178
|
+
const healthBody: Record<string, unknown> = {
|
|
179
|
+
schemaVersion: activeV2Only ? BOOKING_SURFACE_SCHEMA_VERSION_V2 : BOOKING_SURFACE_SCHEMA_VERSION,
|
|
180
|
+
schemaSha256: activeV2Only ? BOOKING_SURFACE_SCHEMA_V2_SHA256 : BOOKING_SURFACE_SCHEMA_SHA256,
|
|
181
|
+
status: 'ready',
|
|
182
|
+
}
|
|
183
|
+
if (activeV2Only || v2) healthBody.supportedSchemaVersions = activeV2Only ? [BOOKING_SURFACE_SCHEMA_VERSION_V2] : [BOOKING_SURFACE_SCHEMA_VERSION, BOOKING_SURFACE_SCHEMA_VERSION_V2]
|
|
184
|
+
if (v2) {
|
|
185
|
+
const acceptedTurnKinds = v2.ingressMode === 'bff-ingress-binding'
|
|
186
|
+
? [...BOOKING_COPILOT_V2_ACCEPTED_TURN_KINDS, BOOKING_COPILOT_V2_INGRESS_TURN_KIND]
|
|
187
|
+
: [...BOOKING_COPILOT_V2_ACCEPTED_TURN_KINDS]
|
|
188
|
+
healthBody.ingressMode = v2.ingressMode
|
|
189
|
+
healthBody.acceptedTurnKinds = acceptedTurnKinds
|
|
190
|
+
res.setHeader('X-GoTry-Ingress-Mode', v2.ingressMode!)
|
|
191
|
+
res.setHeader('X-GoTry-Accepted-Turn-Kinds', acceptedTurnKinds.join(','))
|
|
192
|
+
}
|
|
193
|
+
sendJson(res, 200, healthBody)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const schemaVersion = String(req.headers[BOOKING_SURFACE_VERSION_HEADER] ?? '')
|
|
197
|
+
const schemaHash = String(req.headers[BOOKING_SURFACE_SCHEMA_SHA256_HEADER] ?? '')
|
|
198
|
+
if (schemaVersion === BOOKING_SURFACE_SCHEMA_VERSION_V2) {
|
|
199
|
+
if (!v2 || schemaHash !== BOOKING_SURFACE_SCHEMA_V2_SHA256) {
|
|
200
|
+
sendJson(res, 409, { error: { code: 'booking_surface_schema_mismatch', expectedVersion: BOOKING_SURFACE_SCHEMA_VERSION_V2, expectedSchemaSha256: BOOKING_SURFACE_SCHEMA_V2_SHA256 } })
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
await handleBookingCopilotV2Request(req, res, v2, maxBodyBytes)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (schemaVersion !== BOOKING_SURFACE_SCHEMA_VERSION || schemaHash !== BOOKING_SURFACE_SCHEMA_SHA256) {
|
|
207
|
+
sendJson(res, 409, {
|
|
208
|
+
error: {
|
|
209
|
+
code: 'booking_surface_schema_mismatch',
|
|
210
|
+
expectedVersion: BOOKING_SURFACE_SCHEMA_VERSION,
|
|
211
|
+
expectedSchemaSha256: BOOKING_SURFACE_SCHEMA_SHA256,
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (!options.runtime || !options.plannerFactory) {
|
|
217
|
+
sendJson(res, 503, { error: { code: 'booking_surface_v1_not_configured' } })
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let turn: BookingCopilotTurnV1
|
|
222
|
+
try {
|
|
223
|
+
const raw = await readBody(req, maxBodyBytes)
|
|
224
|
+
const parsed = JSON.parse(raw) as unknown
|
|
225
|
+
const validation = validateBookingCopilotTurnV1(parsed)
|
|
226
|
+
if (!validation.ok) {
|
|
227
|
+
sendJson(res, 400, { error: { code: 'invalid_booking_surface_turn', details: validation.errors } })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
turn = parsed as BookingCopilotTurnV1
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const code = normalizeBookingErrorCode(error)
|
|
233
|
+
sendJson(res, code === 'PAYLOAD_TOO_LARGE' ? 413 : 400, { error: { code } })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let task: BookingCopilotTaskStateV1
|
|
238
|
+
let isNewTask = false
|
|
239
|
+
try {
|
|
240
|
+
const taskId = turn.taskId
|
|
241
|
+
if (v2 && taskId && v2.runtime.resumeTask(taskId)) throw new Error('task_conflict:protocol_version')
|
|
242
|
+
if (turn.kind === 'user.turn') {
|
|
243
|
+
const suppliedTaskId = turn.taskId
|
|
244
|
+
isNewTask = !suppliedTaskId || options.runtime.resumeTask(suppliedTaskId) === null
|
|
245
|
+
task = options.runtime.startTask(turn)
|
|
246
|
+
} else {
|
|
247
|
+
task = options.runtime.continueWithReceipt(turn)
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
sendJson(res, 409, { error: { code: normalizeBookingErrorCode(error) } })
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
res.writeHead(200, {
|
|
255
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
256
|
+
'cache-control': 'no-cache, no-transform',
|
|
257
|
+
connection: 'keep-alive',
|
|
258
|
+
'x-content-type-options': 'nosniff',
|
|
259
|
+
[BOOKING_SURFACE_VERSION_HEADER]: BOOKING_SURFACE_SCHEMA_VERSION,
|
|
260
|
+
[BOOKING_SURFACE_SCHEMA_SHA256_HEADER]: BOOKING_SURFACE_SCHEMA_SHA256,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
if (isNewTask) writeSse(res, options.runtime.emitEvent(task.taskId, { kind: 'status', status: 'submitted' }))
|
|
265
|
+
writeSse(res, options.runtime.emitEvent(task.taskId, { kind: 'status', status: 'working' }))
|
|
266
|
+
task = options.runtime.resumeTask(task.taskId) ?? task
|
|
267
|
+
let session = sessions.get(task.taskId)
|
|
268
|
+
if (!session) {
|
|
269
|
+
session = options.plannerFactory(task)
|
|
270
|
+
sessions.set(task.taskId, session)
|
|
271
|
+
}
|
|
272
|
+
const decisions = await session.next({ turn, task })
|
|
273
|
+
if (decisions.length === 0) {
|
|
274
|
+
writeSse(res, options.runtime.emitEvent(task.taskId, {
|
|
275
|
+
kind: 'error',
|
|
276
|
+
error: { code: 'PLANNER_NO_DECISION', message: 'Planner returned no typed decision.', retryable: false },
|
|
277
|
+
}))
|
|
278
|
+
} else {
|
|
279
|
+
for (const decision of decisions) {
|
|
280
|
+
const event = decision.kind === 'operation'
|
|
281
|
+
? options.runtime.issueOperation(task.taskId, decision.action)
|
|
282
|
+
: options.runtime.emitEvent(task.taskId, decision)
|
|
283
|
+
writeSse(res, event)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
try {
|
|
288
|
+
writeSse(res, options.runtime.emitEvent(task.taskId, {
|
|
289
|
+
kind: 'error',
|
|
290
|
+
error: { code: normalizeBookingErrorCode(error), message: safeBookingErrorMessage(normalizeBookingErrorCode(error)), retryable: false },
|
|
291
|
+
}))
|
|
292
|
+
} catch {
|
|
293
|
+
// Headers are already committed. Preserve a non-empty typed SSE error
|
|
294
|
+
// even when the durable event append is unavailable.
|
|
295
|
+
writeSse(res, {
|
|
296
|
+
schemaVersion: 'booking.surface.v1',
|
|
297
|
+
eventId: `error-${task.taskId}-${task.lastSequence + 1}`,
|
|
298
|
+
taskId: task.taskId,
|
|
299
|
+
contextRef: task.contextRef,
|
|
300
|
+
sequence: task.lastSequence + 1,
|
|
301
|
+
emittedAt: new Date().toISOString(),
|
|
302
|
+
kind: 'error',
|
|
303
|
+
error: { code: normalizeBookingErrorCode(error), message: safeBookingErrorMessage(normalizeBookingErrorCode(error)), retryable: false },
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
res.end()
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
return new Promise((resolve, reject) => {
|
|
312
|
+
server.once('error', reject)
|
|
313
|
+
server.listen(options.port ?? 0, options.host ?? '127.0.0.1', () => {
|
|
314
|
+
server.off('error', reject)
|
|
315
|
+
const address = server.address()
|
|
316
|
+
const port = typeof address === 'object' && address ? address.port : (options.port ?? 0)
|
|
317
|
+
resolve({
|
|
318
|
+
server,
|
|
319
|
+
port,
|
|
320
|
+
close: () => new Promise<void>((done, fail) => server.close((error) => error ? fail(error) : done())),
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
}
|