@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +42 -16
  2. package/README.zh-CN.md +37 -15
  3. package/bin/gotry-bootstrap.js +236 -3
  4. package/bin/gotry-inner.js +66 -20
  5. package/bin/gotry-runtime-resolution.d.ts +1 -1
  6. package/bin/gotry-runtime-resolution.js +7 -8
  7. package/cordis.gotry-patch.yml +15 -5
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/channel-health.js +103 -0
  11. package/dist/capabilities/channel-registry.js +213 -0
  12. package/dist/capabilities/doctor.js +285 -0
  13. package/dist/capabilities/effect.js +178 -5
  14. package/dist/capabilities/flyai.js +13 -0
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  16. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  17. package/dist/capabilities/session/extension-bridge.js +14 -1
  18. package/dist/capabilities/session/extension-channel.js +4 -2
  19. package/dist/capabilities/session/extension-distribution.js +1 -1
  20. package/dist/capabilities/session-search.js +311 -0
  21. package/dist/capabilities/visa-policy.js +126 -0
  22. package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
  23. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  24. package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
  25. package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
  26. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  27. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  28. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  29. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  30. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  31. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  32. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  33. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  34. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  35. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  36. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  37. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  38. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  39. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  40. package/dist/scripts/bootstrap-tests.js +87 -2
  41. package/dist/scripts/build-metrics-report.js +339 -0
  42. package/dist/scripts/channel-probe-tests.js +148 -0
  43. package/dist/scripts/channel-probe.js +252 -0
  44. package/dist/scripts/channel-registry-tests.js +262 -0
  45. package/dist/scripts/doctor-tests.js +121 -0
  46. package/dist/scripts/effect-tests.js +274 -1
  47. package/dist/scripts/extension-tests.js +129 -11
  48. package/dist/scripts/fact-gate-tests.js +91 -2
  49. package/dist/scripts/flyai-tests.js +17 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  51. package/dist/scripts/metrics-report-tests.js +288 -0
  52. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  53. package/dist/scripts/session-tests.js +401 -1
  54. package/dist/scripts/smoke.js +100 -83
  55. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  56. package/dist/scripts/turn-handoff-collect.js +189 -0
  57. package/dist/scripts/turn-policy-tests.js +56 -0
  58. package/dist/scripts/typed-contract-canary.js +261 -0
  59. package/dist/scripts/visa-policy-tests.js +75 -0
  60. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  61. package/dist/src/artifact-gate.js +91 -3
  62. package/dist/src/benchmark-agent-conformance.js +7 -4
  63. package/dist/src/benchmark-environment-bridge.js +68 -7
  64. package/dist/src/bookable-facts.js +62 -3
  65. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  66. package/dist/src/booking-surface/canonical-schema.js +8 -22
  67. package/dist/src/booking-surface/contracts.js +70 -19
  68. package/dist/src/booking-surface/dsh-planner.js +310 -177
  69. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  70. package/dist/src/booking-surface/index.js +1 -5
  71. package/dist/src/booking-surface/profile.js +1 -1
  72. package/dist/src/booking-surface/runtime.js +1682 -247
  73. package/dist/src/booking-surface/server.js +371 -185
  74. package/dist/src/booking-surface/startup.js +17 -27
  75. package/dist/src/booking-surface/validation.js +270 -760
  76. package/dist/src/index.js +735 -149
  77. package/dist/src/turn-deadline.js +292 -0
  78. package/dist/src/turn-policy.js +146 -0
  79. package/dist/src/wish-pool.js +5 -0
  80. package/extension/background.js +23 -4
  81. package/extension/content-main.js +47 -9
  82. package/extension/manifest.json +27 -9
  83. package/package.json +6 -26
  84. package/schemas/booking.surface.schema.json +2593 -0
  85. package/ts/capabilities/agent-reach.ts +4 -4
  86. package/ts/capabilities/flyai.ts +21 -3
  87. package/ts/capabilities/session/action-cache.ts +1 -1
  88. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  89. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  90. package/ts/capabilities/session/extension-bridge.ts +32 -2
  91. package/ts/capabilities/session/extension-channel.ts +9 -8
  92. package/ts/capabilities/session/extension-distribution.ts +3 -1
  93. package/ts/capabilities/session/health-watch.ts +1 -1
  94. package/ts/capabilities/session/read-guard.ts +1 -1
  95. package/ts/capabilities/session/wizard.ts +1 -1
  96. package/ts/capabilities/session-search.ts +323 -1
  97. package/ts/package.json +1 -0
  98. package/ts/scripts/turn-handoff-collect.ts +178 -0
  99. package/ts/src/artifact-gate.ts +82 -2
  100. package/ts/src/benchmark-agent-conformance.ts +14 -6
  101. package/ts/src/benchmark-environment-bridge.ts +29 -10
  102. package/ts/src/bookable-facts.ts +109 -5
  103. package/ts/src/booking-saga.ts +1 -1
  104. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  105. package/ts/src/booking-surface/canonical-schema.js +8 -22
  106. package/ts/src/booking-surface/contracts.ts +278 -239
  107. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  108. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  109. package/ts/src/booking-surface/index.ts +1 -5
  110. package/ts/src/booking-surface/profile.ts +11 -11
  111. package/ts/src/booking-surface/runtime.ts +1351 -351
  112. package/ts/src/booking-surface/server.ts +282 -175
  113. package/ts/src/booking-surface/startup.ts +36 -48
  114. package/ts/src/booking-surface/validation.ts +194 -442
  115. package/ts/src/contracts.ts +1 -1
  116. package/ts/src/index.ts +489 -171
  117. package/ts/src/loop.ts +1 -1
  118. package/ts/src/state-ledger.ts +1 -1
  119. package/ts/src/turn-deadline.ts +361 -0
  120. package/ts/src/turn-policy.ts +154 -0
  121. package/ts/src/wish-pool.ts +17 -4
  122. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  123. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  124. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  125. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  126. package/dist/src/booking-surface/contracts-v2.js +0 -89
  127. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  128. package/dist/src/booking-surface/server-v2.js +0 -334
  129. package/dist/src/booking-surface/validation-v2.js +0 -319
  130. package/dist/src/tool-budget.js +0 -136
  131. package/schemas/booking.surface.v1.schema.json +0 -927
  132. package/schemas/booking.surface.v2.schema.json +0 -61
  133. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  134. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  135. package/ts/src/booking-surface/server-v2.ts +0 -247
  136. package/ts/src/booking-surface/validation-v2.ts +0 -205
  137. package/ts/src/tool-budget.ts +0 -165
@@ -9,75 +9,89 @@
9
9
  import { timingSafeEqual } from 'node:crypto'
10
10
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
11
11
  import {
12
+ BOOKING_COPILOT_ACCEPTED_TURN_KINDS,
13
+ BOOKING_COPILOT_INGRESS_MODES,
14
+ BOOKING_READ_ACTION_KINDS,
15
+ BOOKING_SURFACE_ALLOWED_ACTIONS,
12
16
  BOOKING_SURFACE_SCHEMA_SHA256,
13
17
  BOOKING_SURFACE_SCHEMA_SHA256_HEADER,
14
18
  BOOKING_SURFACE_SCHEMA_VERSION,
15
19
  BOOKING_SURFACE_VERSION_HEADER,
16
- type BookingCopilotTurnV1,
17
- type BookingReadActionV1,
18
- type BookingSurfaceEventV1,
20
+ type BookingCopilotIngressMode,
21
+ type BookingCopilotTurn,
22
+ type BookingIngressBinding,
23
+ type BookingIngressPrincipal,
24
+ type BookingReadActionKind,
25
+ type BookingSurfaceEvent,
26
+ type BookingWorkspaceSnapshot,
27
+ type IngressTurn,
19
28
  } from './contracts.ts'
20
29
  import {
30
+ bookingDigest,
21
31
  BookingCopilotTaskRuntime,
22
- type BookingCopilotTaskStateV1,
23
- type BookingSurfaceEventDraftV1,
32
+ type BookingCopilotTaskState,
33
+ type BookingIngressRequestBindingInput,
34
+ type BookingPlannerSessionFactory,
24
35
  } from './runtime.ts'
25
- import { validateBookingCopilotTurnV1 } from './validation.ts'
26
- import { handleBookingCopilotV2Request, type BookingCopilotV2Adapter } from './server-v2.ts'
36
+ import { validateBookingSurface } from './validation.ts'
27
37
  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
38
 
41
- export interface BookingPlannerSessionV1 {
42
- next(input: {
43
- turn: BookingCopilotTurnV1
44
- task: BookingCopilotTaskStateV1
45
- }): Promise<readonly BookingPlannerDecisionV1[]>
39
+ export interface BookingCopilotComposition {
40
+ runtime: BookingCopilotTaskRuntime
41
+ plannerFactory: BookingPlannerSessionFactory
42
+ /** Optional BFF-owned identity binding. GoTry never accepts browser identity. */
43
+ ingressBinding?: BookingIngressBinding
44
+ /** Principal authenticated by the HotelByte BFF, never by browser input. */
45
+ principal?: BookingIngressPrincipal
46
+ /** Explicit process composition mode; omitted means infer from a complete pair. */
47
+ ingressMode?: BookingCopilotIngressMode
46
48
  }
47
49
 
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 {
50
+ export interface BookingCopilotServerOptions extends BookingCopilotComposition {
54
51
  /** Deployment credential shared only with the HotelByte BFF. */
55
52
  apiKey: string
56
- runtime?: BookingCopilotTaskRuntime
57
- plannerFactory?: BookingPlannerSessionFactoryV1
58
53
  host?: string
59
54
  port?: number
60
55
  maxBodyBytes?: number
61
56
  /** GoTry source commit loaded from the root-owned release artifact. */
62
57
  artifactId?: string
63
- /** Optional v2 adapter dispatched by the same listener after version/hash selection. */
64
- v2?: BookingCopilotV2Adapter
65
58
  }
66
59
 
67
- export interface BookingCopilotServerHandleV1 {
60
+ export interface BookingCopilotServerHandle {
68
61
  server: Server
69
62
  port: number
70
63
  close(): Promise<void>
71
64
  }
72
65
 
73
- interface BookingCopilotRuntimeIdentityV1 {
66
+ interface BookingCopilotRuntimeIdentity {
74
67
  nodeVersion: string
75
68
  nodeModulesAbi: string
76
69
  releaseTuple: string
77
70
  glibcVersion: string
78
71
  }
79
72
 
80
- function runtimeIdentity(): BookingCopilotRuntimeIdentityV1 {
73
+ const sessionsByComposition = new WeakMap<BookingCopilotComposition, Map<string, ReturnType<BookingPlannerSessionFactory>>>()
74
+ const decisionFlightsByComposition = new WeakMap<BookingCopilotComposition, Map<string, Promise<BookingSurfaceEvent[]>>>()
75
+ function sessionsFor(composition: BookingCopilotComposition): Map<string, ReturnType<BookingPlannerSessionFactory>> {
76
+ let sessions = sessionsByComposition.get(composition)
77
+ if (!sessions) { sessions = new Map(); sessionsByComposition.set(composition, sessions) }
78
+ return sessions
79
+ }
80
+ function decisionFlightsFor(composition: BookingCopilotComposition): Map<string, Promise<BookingSurfaceEvent[]>> {
81
+ let flights = decisionFlightsByComposition.get(composition)
82
+ if (!flights) { flights = new Map(); decisionFlightsByComposition.set(composition, flights) }
83
+ return flights
84
+ }
85
+ async function runDecisionSingleFlight(composition: BookingCopilotComposition, key: string, work: () => Promise<BookingSurfaceEvent[]>): Promise<BookingSurfaceEvent[]> {
86
+ const flights = decisionFlightsFor(composition)
87
+ const prior = flights.get(key)
88
+ if (prior) return prior
89
+ const current = work()
90
+ flights.set(key, current)
91
+ try { return await current } finally { if (flights.get(key) === current) flights.delete(key) }
92
+ }
93
+
94
+ function runtimeIdentity(): BookingCopilotRuntimeIdentity {
81
95
  const report = process.report?.getReport?.() as { header?: { glibcVersionRuntime?: string } } | undefined
82
96
  const header = report?.header
83
97
  const glibcVersion = header?.glibcVersionRuntime ?? ''
@@ -118,40 +132,237 @@ function readBody(req: IncomingMessage, maxBodyBytes: number): Promise<string> {
118
132
  })
119
133
  }
120
134
 
121
- function writeSse(res: ServerResponse, event: BookingSurfaceEventV1): void {
135
+ function write(res: ServerResponse, event: BookingSurfaceEvent): void {
122
136
  res.write(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`)
123
137
  }
124
138
 
125
- export function startBookingCopilotServer(options: BookingCopilotServerOptionsV1): Promise<BookingCopilotServerHandleV1> {
139
+ function sameCapabilities(a: readonly string[], b: readonly string[]): boolean {
140
+ return a.length === b.length && [...a].sort().every((kind, index) => kind === [...b].sort()[index])
141
+ }
142
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
143
+ return Object.keys(value).length === allowed.length && Object.keys(value).every((key) => allowed.includes(key))
144
+ }
145
+
146
+ const SURFACE_ACTION_MATRIX = BOOKING_SURFACE_ALLOWED_ACTIONS
147
+
148
+ function validSurfaceActions(surface: unknown, allowed: unknown): allowed is BookingWorkspaceSnapshot['capabilities']['allowedActions'] {
149
+ if (typeof surface !== 'string' || !Object.prototype.hasOwnProperty.call(SURFACE_ACTION_MATRIX, surface)) return false
150
+ if (!Array.isArray(allowed) || allowed.length < 1 || new Set(allowed).size !== allowed.length) return false
151
+ const matrix = SURFACE_ACTION_MATRIX[surface as BookingWorkspaceSnapshot['surface']]
152
+ return allowed.every((action) => typeof action === 'string'
153
+ && matrix.includes(action as BookingReadActionKind)
154
+ && (BOOKING_READ_ACTION_KINDS as readonly string[]).includes(action))
155
+ }
156
+
157
+ function validBoundWorkspaceAuthority(workspace: BookingWorkspaceSnapshot): boolean {
158
+ return workspace.capabilities.surface === workspace.surface
159
+ && validSurfaceActions(workspace.surface, workspace.capabilities.allowedActions)
160
+ }
161
+
162
+ function validIngressBinding(binding: unknown, ingress: IngressTurn): binding is {
163
+ taskId: string
164
+ turnId: string
165
+ contextRef: string
166
+ surface: BookingWorkspaceSnapshot['surface']
167
+ allowedActions: BookingWorkspaceSnapshot['capabilities']['allowedActions']
168
+ } {
169
+ if (!binding || typeof binding !== 'object' || Array.isArray(binding)) return false
170
+ const candidate = binding as Record<string, unknown>
171
+ if (!exactKeys(candidate, ['taskId', 'turnId', 'contextRef', 'surface', 'allowedActions'])) return false
172
+ if (typeof candidate.taskId !== 'string' || typeof candidate.turnId !== 'string' || typeof candidate.contextRef !== 'string') return false
173
+ if (typeof candidate.surface !== 'string' || candidate.surface !== ingress.surfaceHint || !Object.prototype.hasOwnProperty.call(SURFACE_ACTION_MATRIX, candidate.surface)) return false
174
+ return validSurfaceActions(candidate.surface, candidate.allowedActions)
175
+ }
176
+
177
+ function validIngressApprovalAuthority(ingress: IngressTurn, binding: { taskId: string; contextRef: string }): boolean {
178
+ if (!ingress.request.approval) return true
179
+ return ingress.request.approval.taskId === binding.taskId
180
+ && ingress.request.approval.contextRef === binding.contextRef
181
+ }
182
+
183
+ function fallbackErrorEvent(task: BookingCopilotTaskState, code: string): BookingSurfaceEvent {
184
+ return {
185
+ schemaVersion: BOOKING_SURFACE_SCHEMA_VERSION,
186
+ eventId: `error-${task.taskId}-${task.lastSequence + 1}`,
187
+ taskId: task.taskId,
188
+ contextRef: task.contextRef,
189
+ sequence: task.lastSequence + 1,
190
+ emittedAt: new Date().toISOString(),
191
+ kind: 'error',
192
+ error: { code: normalizeBookingErrorCode(code), message: safeBookingErrorMessage(normalizeBookingErrorCode(code)), retryable: false },
193
+ }
194
+ }
195
+
196
+ function writeTypedError(res: ServerResponse, composition: BookingCopilotComposition, task: BookingCopilotTaskState, error: unknown, requestKey?: string, includeSubmitted = false): void {
197
+ const code = normalizeBookingErrorCode(error)
198
+ // Operator-side diagnosability only: the raw boundary error stays on the
199
+ // server stderr/journal; clients keep receiving the safe typed vocabulary.
200
+ console.error(`[booking-copilot] planner boundary error (${code}):`, error instanceof Error ? error.stack || error.message : error)
201
+ try {
202
+ const decision = { kind: 'error' as const, error: { code, message: safeBookingErrorMessage(code), retryable: false } }
203
+ if (requestKey) {
204
+ const events = composition.runtime.applyDecisionBatch(task.taskId, requestKey, [decision], includeSubmitted)
205
+ for (const event of events) write(res, event)
206
+ return
207
+ }
208
+ write(res, composition.runtime.emitEvent(task.taskId, decision))
209
+ } catch {
210
+ // The stream is already committed. Emit a schema-valid, non-durable
211
+ // typed envelope instead of silently returning an empty HTTP 200 stream.
212
+ write(res, fallbackErrorEvent(task, code))
213
+ }
214
+ }
215
+
216
+ /** Handles one already-authenticated request inside the sole HTTP listener. */
217
+ async function handleBookingCopilotRequest(req: IncomingMessage, res: ServerResponse, composition: BookingCopilotComposition, maxBodyBytes: number): Promise<void> {
218
+ let turn: Exclude<BookingCopilotTurn, IngressTurn>
219
+ let browserRequestKey: string | undefined
220
+ let requestBinding: BookingIngressRequestBindingInput | undefined
221
+ try {
222
+ const parsed = JSON.parse(await readBody(req, maxBodyBytes)) as unknown
223
+ const valid = validateBookingSurface(parsed)
224
+ if (!valid.ok) { sendJson(res, 400, { error: { code: 'invalid_booking_surface_turn', details: valid.errors } }); return }
225
+ if ((parsed as BookingCopilotTurn).kind === 'user.turn.ingress') {
226
+ const ingress = parsed as IngressTurn
227
+ browserRequestKey = ingress.requestKey
228
+ if (composition.ingressMode !== 'bff-ingress-binding' || !composition.ingressBinding || !composition.principal) {
229
+ sendJson(res, 503, { error: { code: 'trusted_ingress_binding_required', mode: composition.ingressMode ?? 'bff-bound-turn-only', acceptedTurnKinds: [...BOOKING_COPILOT_ACCEPTED_TURN_KINDS] } })
230
+ return
231
+ }
232
+ const binding = await composition.ingressBinding.bind(ingress, composition.principal)
233
+ if (!validIngressBinding(binding, ingress)) { sendJson(res, 502, { error: { code: 'invalid_ingress_binding' } }); return }
234
+ if (!validIngressApprovalAuthority(ingress, binding)) { sendJson(res, 400, { error: { code: 'invalid_ingress_approval_authority' } }); return }
235
+ const boundWorkspace = {
236
+ ...ingress.workspace,
237
+ contextRef: binding.contextRef,
238
+ surface: binding.surface,
239
+ capabilities: { surface: binding.surface, allowedActions: [...binding.allowedActions] },
240
+ } as BookingWorkspaceSnapshot
241
+ const boundRequest = ingress.request.approval
242
+ ? { text: ingress.request.text, approval: { ...ingress.request.approval } }
243
+ : { text: ingress.request.text }
244
+ turn = { schemaVersion: BOOKING_SURFACE_SCHEMA_VERSION, kind: 'user.turn', taskId: binding.taskId, turnId: binding.turnId, workspace: boundWorkspace, request: boundRequest }
245
+ requestBinding = { requestKey: ingress.requestKey, principal: composition.principal, ...(ingress.taskHandle ? { taskHandle: ingress.taskHandle } : {}) }
246
+ const boundValid = validateBookingSurface(turn)
247
+ if (!boundValid.ok) { sendJson(res, 502, { error: { code: 'invalid_ingress_binding', details: boundValid.errors } }); return }
248
+ } else {
249
+ turn = parsed as Exclude<BookingCopilotTurn, IngressTurn>
250
+ if (!validBoundWorkspaceAuthority(turn.workspace)) {
251
+ sendJson(res, 403, { error: { code: 'invalid_bound_turn_authority' } })
252
+ return
253
+ }
254
+ }
255
+ } catch (error) { const code = normalizeBookingErrorCode(error); sendJson(res, code === 'PAYLOAD_TOO_LARGE' ? 413 : 400, { error: { code } }); return }
256
+
257
+ let task: BookingCopilotTaskState; let fresh = false; let replayKey: string | undefined; let replayEvents: BookingSurfaceEvent[] | null = null; let activeDecisionKey: string | undefined
258
+ try {
259
+ fresh = turn.kind === 'action.receipt.continuation' ? false : !turn.taskId || composition.runtime.resumeTask(turn.taskId) === null
260
+ if (turn.kind === 'action.receipt.continuation') {
261
+ replayKey = `receipt:${turn.receipt.actionId}:${composition.runtime.receiptDigest(turn.receipt)}`
262
+ task = composition.runtime.continueWithReceipt(turn)
263
+ if (task.awaitingApproval) {
264
+ replayKey = composition.runtime.approvalPresentationRequestKey(turn.taskId)
265
+ }
266
+ replayEvents = composition.runtime.readDecisionBatch(turn.taskId, replayKey)
267
+ if (!replayEvents && task.phase === 'terminal') replayEvents = composition.runtime.terminalDecisionBatch(turn.taskId, replayKey)
268
+ } else {
269
+ const existing = turn.taskId ? composition.runtime.resumeTask(turn.taskId) : null
270
+ if (requestBinding && turn.kind === 'user.turn') composition.runtime.assertRequestBinding(browserRequestKey!, turn, requestBinding)
271
+ const requestReplayKey = existing && browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : undefined
272
+ const directReplayKey = existing && turn.kind === 'user.turn' && turn.turnId ? `turn:${existing.taskId}:${turn.turnId}` : undefined
273
+ const stableReplayKey = requestReplayKey ?? directReplayKey
274
+ const stableReplay = stableReplayKey ? composition.runtime.readDecisionBatch(existing!.taskId, stableReplayKey) : null
275
+ if (stableReplay) {
276
+ if (existing && turn.kind === 'user.turn' && directReplayKey) composition.runtime.assertTurnBinding(existing.taskId, turn)
277
+ task = existing!
278
+ replayKey = stableReplayKey
279
+ replayEvents = stableReplay
280
+ } else if (existing?.phase === 'terminal' || existing?.phase === 'error') {
281
+ throw new Error('task_terminal')
282
+ } else if (existing?.phase === 'waiting_receipt') {
283
+ if (turn.kind === 'user.turn' && turn.request.approval) composition.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval)
284
+ const workspace = turn.workspace as BookingWorkspaceSnapshot
285
+ 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')
286
+ // A waiting task can only be replayed with the caller's stable turn
287
+ // identity. Without it, an identical sentence is not distinguishable
288
+ // from a new user turn and must not receive an old operation batch.
289
+ if (!turn.turnId || existing.lastTurnId !== turn.turnId) throw new Error('receipt_required')
290
+ replayKey = browserRequestKey ? `ingress:${existing.taskId}:${browserRequestKey}` : `turn:${existing.taskId}:${existing.lastTurnId ?? existing.userTurnCount}`
291
+ replayEvents = composition.runtime.readDecisionBatch(existing.taskId, replayKey)
292
+ if (!replayEvents) throw new Error('receipt_required')
293
+ task = existing
294
+ } else {
295
+ if (existing && turn.kind === 'user.turn' && turn.request.approval) composition.runtime.assertApprovalNotConsumed(existing.taskId, turn.request.approval)
296
+ task = composition.runtime.startTask(turn, requestBinding)
297
+ if (turn.kind === 'user.turn' && turn.taskId) {
298
+ replayKey = browserRequestKey ? `ingress:${task.taskId}:${browserRequestKey}` : `turn:${task.taskId}:${turn.turnId ?? task.userTurnCount}`
299
+ replayEvents = composition.runtime.readDecisionBatch(task.taskId, replayKey)
300
+ }
301
+ }
302
+ }
303
+ } catch (error) { sendJson(res, 409, { error: { code: normalizeBookingErrorCode(error) } }); return }
304
+
305
+ 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', [BOOKING_SURFACE_VERSION_HEADER]: BOOKING_SURFACE_SCHEMA_VERSION, [BOOKING_SURFACE_SCHEMA_SHA256_HEADER]: BOOKING_SURFACE_SCHEMA_SHA256 })
306
+ try {
307
+ if (replayEvents) {
308
+ for (const event of replayEvents) write(res, event)
309
+ } else {
310
+ const requestKey = replayKey ?? `turn:${task.taskId}:${task.userTurnCount}`
311
+ activeDecisionKey = requestKey
312
+ const approval = turn.kind === 'user.turn' ? turn.request.approval : undefined
313
+ const flightKey = JSON.stringify([task.taskId, approval ? `approval:${bookingDigest(approval)}` : requestKey])
314
+ const decisionEvents = await runDecisionSingleFlight(composition, flightKey, async () => {
315
+ if (turn.kind === 'action.receipt.continuation' && task.awaitingApproval) {
316
+ return composition.runtime.applyDecisionBatch(task.taskId, requestKey, [composition.runtime.approvalQuestion(task.taskId)], false)
317
+ }
318
+ const sessions = sessionsFor(composition)
319
+ const session = sessions.get(task.taskId) ?? composition.plannerFactory(task)
320
+ sessions.set(task.taskId, session)
321
+ const decisions = await session.next({ turn, task: composition.runtime.resumeTask(task.taskId) ?? task })
322
+ const plannerDecisions = decisions.length ? decisions : [{ kind: 'error' as const, error: { code: 'PLANNER_NO_DECISION', message: 'Planner returned no typed decision.', retryable: false } }]
323
+ return composition.runtime.applyDecisionBatch(task.taskId, requestKey, plannerDecisions, fresh)
324
+ })
325
+ for (const event of decisionEvents) write(res, event)
326
+ }
327
+ } catch (error) {
328
+ writeTypedError(res, composition, task, error, activeDecisionKey, fresh)
329
+ } finally { res.end() }
330
+ }
331
+
332
+ export function startBookingCopilotServer(options: BookingCopilotServerOptions): Promise<BookingCopilotServerHandle> {
126
333
  if (!options.apiKey) return Promise.reject(new Error('booking_copilot_api_key_required'))
127
334
  if (options.artifactId !== undefined && !/^[0-9a-f]{40}$/.test(options.artifactId)) {
128
335
  return Promise.reject(new Error('booking_copilot_artifact_id_invalid'))
129
336
  }
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'))
337
+ if (!options.runtime || !options.plannerFactory) {
338
+ return Promise.reject(new Error('booking_copilot_runtime_and_planner_required'))
339
+ }
340
+ const hasIngressBinding = Boolean(options.ingressBinding)
341
+ const hasPrincipal = Boolean(options.principal)
342
+ if (hasIngressBinding !== hasPrincipal) {
343
+ return Promise.reject(new Error('booking_copilot_ingress_binding_pair_required'))
344
+ }
345
+ if (hasIngressBinding && (typeof options.ingressBinding?.bind !== 'function'
346
+ || typeof options.principal?.subject !== 'string' || options.principal.subject.length === 0 || options.principal.subject.length > 256
347
+ || typeof options.principal?.scope !== 'string' || options.principal.scope.length === 0 || options.principal.scope.length > 256)) {
348
+ return Promise.reject(new Error('booking_copilot_ingress_binding_invalid'))
134
349
  }
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'))
350
+ const ingressMode: BookingCopilotIngressMode = options.ingressMode ?? (hasIngressBinding ? 'bff-ingress-binding' : 'bff-bound-turn-only')
351
+ if (!BOOKING_COPILOT_INGRESS_MODES.includes(ingressMode)) {
352
+ return Promise.reject(new Error('booking_copilot_ingress_mode_invalid'))
139
353
  }
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'))
354
+ if (ingressMode === 'bff-ingress-binding' && !hasIngressBinding) {
355
+ return Promise.reject(new Error('booking_copilot_ingress_binding_required'))
145
356
  }
146
- if (options.v2 && ingressMode === 'bff-ingress-binding' && !hasIngressBinding) {
147
- return Promise.reject(new Error('booking_copilot_v2_ingress_binding_required'))
357
+ if (ingressMode === 'bff-bound-turn-only' && hasIngressBinding) {
358
+ return Promise.reject(new Error('booking_copilot_ingress_mode_conflict'))
148
359
  }
149
- if (options.v2 && ingressMode === 'bff-bound-turn-only' && hasIngressBinding) {
150
- return Promise.reject(new Error('booking_copilot_v2_ingress_mode_conflict'))
360
+ const composition: BookingCopilotComposition = {
361
+ runtime: options.runtime,
362
+ plannerFactory: options.plannerFactory,
363
+ ...(hasIngressBinding ? { ingressBinding: options.ingressBinding, principal: options.principal } : {}),
364
+ ingressMode,
151
365
  }
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
366
  const maxBodyBytes = options.maxBodyBytes ?? 1_000_000
156
367
  const runningIdentity = runtimeIdentity()
157
368
 
@@ -167,42 +378,30 @@ export function startBookingCopilotServer(options: BookingCopilotServerOptionsV1
167
378
  return
168
379
  }
169
380
  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)
381
+ res.setHeader(BOOKING_SURFACE_VERSION_HEADER, BOOKING_SURFACE_SCHEMA_VERSION)
382
+ res.setHeader(BOOKING_SURFACE_SCHEMA_SHA256_HEADER, BOOKING_SURFACE_SCHEMA_SHA256)
173
383
  if (options.artifactId) res.setHeader('X-GoTry-Artifact-ID', options.artifactId)
174
384
  res.setHeader('X-GoTry-Node-Version', runningIdentity.nodeVersion)
175
385
  res.setHeader('X-GoTry-Node-Modules-ABI', runningIdentity.nodeModulesAbi)
176
386
  res.setHeader('X-GoTry-Release-Tuple', runningIdentity.releaseTuple)
177
387
  if (runningIdentity.glibcVersion) res.setHeader('X-GoTry-Glibc-Version', runningIdentity.glibcVersion)
388
+ const acceptedTurnKinds = ingressMode === 'bff-ingress-binding'
389
+ ? [...BOOKING_COPILOT_ACCEPTED_TURN_KINDS, 'user.turn.ingress' as const]
390
+ : [...BOOKING_COPILOT_ACCEPTED_TURN_KINDS]
178
391
  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,
392
+ schemaVersion: BOOKING_SURFACE_SCHEMA_VERSION,
393
+ schemaSha256: BOOKING_SURFACE_SCHEMA_SHA256,
181
394
  status: 'ready',
395
+ ingressMode,
396
+ acceptedTurnKinds,
182
397
  }
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
- }
398
+ res.setHeader('X-GoTry-Ingress-Mode', ingressMode)
399
+ res.setHeader('X-GoTry-Accepted-Turn-Kinds', acceptedTurnKinds.join(','))
193
400
  sendJson(res, 200, healthBody)
194
401
  return
195
402
  }
196
403
  const schemaVersion = String(req.headers[BOOKING_SURFACE_VERSION_HEADER] ?? '')
197
404
  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
405
  if (schemaVersion !== BOOKING_SURFACE_SCHEMA_VERSION || schemaHash !== BOOKING_SURFACE_SCHEMA_SHA256) {
207
406
  sendJson(res, 409, {
208
407
  error: {
@@ -213,99 +412,7 @@ export function startBookingCopilotServer(options: BookingCopilotServerOptionsV1
213
412
  })
214
413
  return
215
414
  }
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
- }
415
+ await handleBookingCopilotRequest(req, res, composition, maxBodyBytes)
309
416
  })
310
417
 
311
418
  return new Promise((resolve, reject) => {