@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.
Files changed (229) hide show
  1. package/README.md +161 -116
  2. package/README.zh-CN.md +173 -144
  3. package/bin/gotry-booking-copilot.js +53 -0
  4. package/bin/gotry-bootstrap.js +113 -66
  5. package/bin/gotry-inner.js +436 -70
  6. package/bin/gotry-runtime-resolution.d.ts +27 -0
  7. package/bin/gotry-runtime-resolution.js +50 -0
  8. package/bin/gotry.js +1 -1
  9. package/cordis.gotry-patch.yml +5 -1
  10. package/dist/capabilities/agent-reach-deep.js +1 -1
  11. package/dist/capabilities/agent-reach.js +1 -1
  12. package/dist/capabilities/anything.js +1 -1
  13. package/dist/capabilities/artifacts.js +1 -1
  14. package/dist/capabilities/effect.js +1 -1
  15. package/dist/capabilities/fact-log.js +1 -1
  16. package/dist/capabilities/flyai.js +20 -6
  17. package/dist/capabilities/hbcli.js +1 -1
  18. package/dist/capabilities/incident-log.js +1 -1
  19. package/dist/capabilities/model-override.js +18 -0
  20. package/dist/capabilities/opensky.js +1 -1
  21. package/dist/capabilities/resilience.js +1 -1
  22. package/dist/capabilities/session/action-cache.js +1 -1
  23. package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
  24. package/dist/capabilities/session/adapters/meituan-local.js +1 -1
  25. package/dist/capabilities/session/benchmark.js +1 -1
  26. package/dist/capabilities/session/extension-bridge.js +19 -6
  27. package/dist/capabilities/session/extension-channel.js +3 -3
  28. package/dist/capabilities/session/extension-distribution.js +234 -0
  29. package/dist/capabilities/session/extract.js +1 -1
  30. package/dist/capabilities/session/golden-score.js +92 -0
  31. package/dist/capabilities/session/health-watch.js +1 -1
  32. package/dist/capabilities/session/read-guard.js +1 -1
  33. package/dist/capabilities/session/static-flight-golden.js +137 -0
  34. package/dist/capabilities/session/transport.js +1 -1
  35. package/dist/capabilities/session/wizard.js +21 -251
  36. package/dist/capabilities/session-consent.js +1 -1
  37. package/dist/capabilities/session-login.js +11 -5
  38. package/dist/capabilities/session-search.js +46 -4
  39. package/dist/capabilities/weather.js +168 -46
  40. package/dist/data/session-golden-20.json +25 -0
  41. package/dist/data/sf-golden-manifest.json +102 -0
  42. package/dist/data/sf-static-routes.json +91 -0
  43. package/dist/scripts/action-cache-tests.js +1 -1
  44. package/dist/scripts/agent-planning-budget-e2e.js +227 -0
  45. package/dist/scripts/agent-planning-budget-tests.js +173 -0
  46. package/dist/scripts/agent-reach-deep-tests.js +1 -1
  47. package/dist/scripts/agent-reach-tests.js +1 -1
  48. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  49. package/dist/scripts/anything-tests.js +1 -1
  50. package/dist/scripts/async-collect.js +1 -1
  51. package/dist/scripts/benchmark-environment-bridge-e2e.js +981 -0
  52. package/dist/scripts/benchmark-environment-bridge-tests.js +2544 -0
  53. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +335 -0
  54. package/dist/scripts/booking-copilot-availability-policy-v2-tests.js +1355 -0
  55. package/dist/scripts/booking-copilot-bin-proof-tests.js +89 -0
  56. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +113 -0
  57. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +356 -0
  58. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +557 -0
  59. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +140 -0
  60. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +202 -0
  61. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +102 -0
  62. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +374 -0
  63. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +447 -0
  64. package/dist/scripts/booking-copilot-runtime-proof-tests.js +205 -0
  65. package/dist/scripts/booking-copilot-server-proof-tests.js +317 -0
  66. package/dist/scripts/booking-copilot-startup-proof-tests.js +283 -0
  67. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +3935 -0
  68. package/dist/scripts/booking-saga-tests.js +1 -1
  69. package/dist/scripts/booking-surface-contract-proof-tests.js +393 -0
  70. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +1174 -0
  71. package/dist/scripts/bootstrap-tests.js +45 -39
  72. package/dist/scripts/build-changelog.js +1 -1
  73. package/dist/scripts/changelog-tests.js +1 -1
  74. package/dist/scripts/companion-tests.js +1 -1
  75. package/dist/scripts/diff-test.js +1 -1
  76. package/dist/scripts/dsh-runtime-closure-tests.js +225 -0
  77. package/dist/scripts/dsh-runtime-closure.js +150 -0
  78. package/dist/scripts/effect-tests.js +1 -1
  79. package/dist/scripts/engine-run.js +1 -1
  80. package/dist/scripts/engine-tests.js +1 -1
  81. package/dist/scripts/evaluation-cadence-tests.js +347 -0
  82. package/dist/scripts/evaluation-contract-tests.js +574 -0
  83. package/dist/scripts/extension-distribution-cli.js +35 -0
  84. package/dist/scripts/extension-distribution-tests.js +341 -0
  85. package/dist/scripts/extension-tests.js +128 -23
  86. package/dist/scripts/fact-gate-tests.js +1 -1
  87. package/dist/scripts/flyai-tests.js +1 -1
  88. package/dist/scripts/hbcli-e2e-tests.js +1 -1
  89. package/dist/scripts/hbcli-tests.js +1 -1
  90. package/dist/scripts/health-watch-cli.js +1 -1
  91. package/dist/scripts/i18n-tests.js +1 -1
  92. package/dist/scripts/incident-tests.js +1 -1
  93. package/dist/scripts/journey-tests.js +1 -1
  94. package/dist/scripts/ledger-tests.js +1 -1
  95. package/dist/scripts/ledger-workflow-crash.js +1 -1
  96. package/dist/scripts/memory-capture-tests.js +1 -1
  97. package/dist/scripts/memory-decay-tests.js +1 -1
  98. package/dist/scripts/memory-metrics.js +1 -1
  99. package/dist/scripts/memory-value-report.js +1 -1
  100. package/dist/scripts/model-override-e2e.js +176 -0
  101. package/dist/scripts/nightly-evidence-tests.js +1 -1
  102. package/dist/scripts/nightly-evidence.js +1 -1
  103. package/dist/scripts/nudge-digest.js +1 -1
  104. package/dist/scripts/onboarding-tests.js +21 -53
  105. package/dist/scripts/opensky-check.js +1 -1
  106. package/dist/scripts/opensky-tests.js +1 -1
  107. package/dist/scripts/pnpm-dsh-closure-proof.js +20 -0
  108. package/dist/scripts/price-drift-tests.js +1 -1
  109. package/dist/scripts/price-drift-watch.js +1 -1
  110. package/dist/scripts/probe-poi-tests.js +1 -1
  111. package/dist/scripts/product-metrics.js +1 -1
  112. package/dist/scripts/publish-preverify.js +40 -4
  113. package/dist/scripts/realtime-pricing-tests.js +1 -1
  114. package/dist/scripts/replay-async.js +1 -1
  115. package/dist/scripts/replay-real.js +1 -1
  116. package/dist/scripts/replay.js +1 -1
  117. package/dist/scripts/session-attach-diagnose.js +1 -1
  118. package/dist/scripts/session-attach-poc.js +1 -1
  119. package/dist/scripts/session-benchmark.js +1 -1
  120. package/dist/scripts/session-extract-tests.js +1 -1
  121. package/dist/scripts/session-login.js +1 -1
  122. package/dist/scripts/session-tests.js +70 -20
  123. package/dist/scripts/sf-live-benchmark.js +338 -0
  124. package/dist/scripts/sf-live-cli-tests.js +21 -0
  125. package/dist/scripts/sf-soft-score-tests.js +108 -0
  126. package/dist/scripts/sf-summary.js +93 -0
  127. package/dist/scripts/skeleton-check.js +1 -1
  128. package/dist/scripts/skeleton-integration-test.js +1 -1
  129. package/dist/scripts/skills-contract-tests.js +1 -1
  130. package/dist/scripts/smoke-session-gate-tests.js +29 -0
  131. package/dist/scripts/smoke.js +79 -33
  132. package/dist/scripts/state-cli-tests.js +1 -1
  133. package/dist/scripts/state-cli.js +1 -1
  134. package/dist/scripts/static-golden-tests.js +299 -0
  135. package/dist/scripts/time-eval-tests.js +1 -1
  136. package/dist/scripts/travel-timeline-tests.js +1 -1
  137. package/dist/scripts/unified-tests.js +1 -1
  138. package/dist/scripts/weather-tests.js +694 -44
  139. package/dist/scripts/z3-race-tests.js +1 -1
  140. package/dist/src/artifact-gate.js +1 -1
  141. package/dist/src/benchmark-agent-conformance.js +370 -0
  142. package/dist/src/benchmark-environment-bridge.js +384 -0
  143. package/dist/src/benchmark-headless-child-diagnostics.js +173 -0
  144. package/dist/src/benchmark-tool-isolation.js +124 -0
  145. package/dist/src/bookable-facts.js +1 -1
  146. package/dist/src/booking-saga.js +1 -1
  147. package/dist/src/booking-surface/availability-policy-v2.js +830 -0
  148. package/dist/src/booking-surface/canonical-schema.js +113 -0
  149. package/dist/src/booking-surface/contracts-v2.js +89 -0
  150. package/dist/src/booking-surface/contracts.js +46 -0
  151. package/dist/src/booking-surface/dsh-planner.js +453 -0
  152. package/dist/src/booking-surface/dsh-plugin.js +93 -0
  153. package/dist/src/booking-surface/error-codes.js +94 -0
  154. package/dist/src/booking-surface/index.js +15 -0
  155. package/dist/src/booking-surface/profile.js +68 -0
  156. package/dist/src/booking-surface/runtime-v2.js +1771 -0
  157. package/dist/src/booking-surface/runtime.js +351 -0
  158. package/dist/src/booking-surface/server-v2.js +334 -0
  159. package/dist/src/booking-surface/server.js +302 -0
  160. package/dist/src/booking-surface/startup.js +159 -0
  161. package/dist/src/booking-surface/validation-v2.js +319 -0
  162. package/dist/src/booking-surface/validation.js +809 -0
  163. package/dist/src/bridge.js +1 -1
  164. package/dist/src/companions.js +1 -1
  165. package/dist/src/contracts.js +1 -1
  166. package/dist/src/dsh-llm.js +1 -1
  167. package/dist/src/engine.js +1 -1
  168. package/dist/src/evaluation-cadence.js +234 -0
  169. package/dist/src/evaluation-contracts.js +906 -0
  170. package/dist/src/i18n.js +1 -1
  171. package/dist/src/index.js +50 -19
  172. package/dist/src/journey.js +1 -1
  173. package/dist/src/loop.js +1 -1
  174. package/dist/src/memory-capture.js +1 -1
  175. package/dist/src/memory-decay.js +1 -1
  176. package/dist/src/memory-utility.js +1 -1
  177. package/dist/src/mock-llm.js +1 -1
  178. package/dist/src/model.js +1 -1
  179. package/dist/src/realtime-pricing.js +1 -1
  180. package/dist/src/slot-spec.js +1 -1
  181. package/dist/src/state-ledger.js +2 -1
  182. package/dist/src/time-anchor.js +1 -1
  183. package/dist/src/tool-budget.js +136 -0
  184. package/dist/src/tool-packet.js +1 -1
  185. package/dist/src/travel-slots.js +1 -1
  186. package/dist/src/travel-timeline.js +1 -1
  187. package/dist/src/unified.js +1 -1
  188. package/dist/src/wish-pool.js +1 -1
  189. package/dist/src/z3-shared.js +1 -1
  190. package/extension/README.md +31 -7
  191. package/package.json +286 -11
  192. package/schemas/booking.surface.v1.schema.json +927 -0
  193. package/schemas/booking.surface.v2.schema.json +61 -0
  194. package/ts/capabilities/flyai.ts +16 -3
  195. package/ts/capabilities/session/extension-bridge.ts +37 -14
  196. package/ts/capabilities/session/extension-channel.ts +6 -3
  197. package/ts/capabilities/session/extension-distribution.ts +264 -0
  198. package/ts/capabilities/session/golden-score.ts +139 -0
  199. package/ts/capabilities/session/health-watch.ts +1 -1
  200. package/ts/capabilities/session/static-flight-golden.ts +209 -0
  201. package/ts/capabilities/session/wizard.ts +34 -176
  202. package/ts/capabilities/session-login.ts +15 -5
  203. package/ts/capabilities/session-search.ts +40 -3
  204. package/ts/capabilities/weather.ts +141 -52
  205. package/ts/package.json +3 -3
  206. package/ts/src/benchmark-agent-conformance.ts +448 -0
  207. package/ts/src/benchmark-environment-bridge.ts +348 -0
  208. package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
  209. package/ts/src/benchmark-tool-isolation.ts +166 -0
  210. package/ts/src/booking-surface/availability-policy-v2.ts +523 -0
  211. package/ts/src/booking-surface/canonical-schema.js +113 -0
  212. package/ts/src/booking-surface/contracts-v2.ts +118 -0
  213. package/ts/src/booking-surface/contracts.ts +380 -0
  214. package/ts/src/booking-surface/dsh-planner.ts +452 -0
  215. package/ts/src/booking-surface/dsh-plugin.js +93 -0
  216. package/ts/src/booking-surface/error-codes.ts +101 -0
  217. package/ts/src/booking-surface/index.ts +12 -0
  218. package/ts/src/booking-surface/profile.ts +42 -0
  219. package/ts/src/booking-surface/runtime-v2.ts +1466 -0
  220. package/ts/src/booking-surface/runtime.ts +483 -0
  221. package/ts/src/booking-surface/server-v2.ts +247 -0
  222. package/ts/src/booking-surface/server.ts +324 -0
  223. package/ts/src/booking-surface/startup.ts +196 -0
  224. package/ts/src/booking-surface/validation-v2.ts +205 -0
  225. package/ts/src/booking-surface/validation.ts +453 -0
  226. package/ts/src/index.ts +64 -11
  227. package/ts/src/state-ledger.ts +1 -0
  228. package/ts/src/tool-budget.ts +165 -0
  229. 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
+ }